From 44fffb2af5d2cde251b39058235b63c7f913d121 Mon Sep 17 00:00:00 2001 From: Samantha Jayasinghe Date: Mon, 4 May 2026 18:29:49 +1200 Subject: [PATCH] SREP-4769 feat: Remove backplane MCP subcommand --- README.md | 187 ------ cmd/ocm-backplane/mcp/mcp.go | 102 ---- cmd/ocm-backplane/mcp/mcp_suite_test.go | 13 - cmd/ocm-backplane/mcp/mcp_test.go | 197 ------ cmd/ocm-backplane/root.go | 2 - go.mod | 5 - go.sum | 12 - pkg/ai/mcp/backplane_cloud_console.go | 86 --- pkg/ai/mcp/backplane_cloud_console_test.go | 449 -------------- pkg/ai/mcp/backplane_cluster_resource.go | 277 --------- pkg/ai/mcp/backplane_cluster_resource_test.go | 578 ------------------ pkg/ai/mcp/backplane_console.go | 81 --- pkg/ai/mcp/backplane_console_test.go | 172 ------ pkg/ai/mcp/backplane_info.go | 82 --- pkg/ai/mcp/backplane_info_test.go | 212 ------- pkg/ai/mcp/backplane_login.go | 47 -- pkg/ai/mcp/backplane_login_test.go | 252 -------- pkg/ai/mcp/mcp_suite_test.go | 13 - pkg/ai/mcp/mcp_tool_integration_test.go | 468 -------------- 19 files changed, 3235 deletions(-) delete mode 100644 cmd/ocm-backplane/mcp/mcp.go delete mode 100644 cmd/ocm-backplane/mcp/mcp_suite_test.go delete mode 100644 cmd/ocm-backplane/mcp/mcp_test.go delete mode 100644 pkg/ai/mcp/backplane_cloud_console.go delete mode 100644 pkg/ai/mcp/backplane_cloud_console_test.go delete mode 100644 pkg/ai/mcp/backplane_cluster_resource.go delete mode 100644 pkg/ai/mcp/backplane_cluster_resource_test.go delete mode 100644 pkg/ai/mcp/backplane_console.go delete mode 100644 pkg/ai/mcp/backplane_console_test.go delete mode 100644 pkg/ai/mcp/backplane_info.go delete mode 100644 pkg/ai/mcp/backplane_info_test.go delete mode 100644 pkg/ai/mcp/backplane_login.go delete mode 100644 pkg/ai/mcp/backplane_login_test.go delete mode 100644 pkg/ai/mcp/mcp_suite_test.go delete mode 100644 pkg/ai/mcp/mcp_tool_integration_test.go diff --git a/README.md b/README.md index b9a7c470..0d578def 100644 --- a/README.md +++ b/README.md @@ -498,190 +498,3 @@ FROM golang:1.24.4 # Update version to match release PR Example Implementation: PR [#636](https://github.com/openshift/backplane-cli/pull/636): OSD-28717 Fix build failures Update the dockerfile of backplane-cli with the latest go version and check if build passes. Check for any issues while updating the dockerfile and start a thread in #sd-ims-backplane channel to mitigate this issue. - -## Backplane MCP - -The Backplane CLI includes a Model Context Protocol (MCP) server that allows AI assistants to interact with backplane resources and retrieve information about your backplane CLI installation and configuration. - -### Overview - -The MCP server provides AI assistants with access to backplane functionality through standardized tools. Currently supported tools: - -- **`info`**: Get comprehensive information about the backplane CLI installation, configuration, and environment -- **`login`**: Login to a backplane cluster -- **`console`**: Access cluster console via backplane CLI, optionally opening in browser -- **`cluster-resource`**: Execute read-only Kubernetes resource operations (get, describe, logs, top, explain) -- **`cloud-console`**: Get cloud provider console access with temporary credentials - -### Running the MCP Server - -The backplane MCP server supports two transport methods: - -#### Stdio Transport (Default) -For direct integration with AI assistants: -```bash -ocm-backplane mcp -``` - -#### HTTP Transport -For web-based access and testing: -```bash -# Run on default port 8080 -ocm-backplane mcp --http - -# Run on custom port -ocm-backplane mcp --http --port 3000 -``` - -### Integration with AI Assistants - -#### Gemini CLI Configuration - -To use the backplane MCP server with Gemini CLI, create or update your Gemini configuration file: - - -```yaml -# Gemini CLI Configuration -tools: - mcp: - servers: - backplane: - command: ["ocm-backplane", "mcp"] - description: "OpenShift Backplane CLI integration" -``` - - -#### Claude Desktop Configuration - -To integrate with Claude Desktop, add the backplane MCP server to your Claude Desktop configuration: - -**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` - -```json -{ - "mcpServers": { - "backplane": { - "command": ["ocm-backplane", "mcp"], - "args": [], - } - } -} -``` - -### Available Tools - -#### `info` - -Retrieves comprehensive information about your backplane CLI installation. - -**What it provides:** -- CLI version information -- Configuration details (URLs, proxy settings, session directory) -- Environment information (home directory, current directory, shell) -- Display and GovCloud settings - -#### `login` - -Login to a backplane cluster. - -**Parameters:** -- `clusterId` (required): The cluster ID to login to - -**CLI Flags:** -- `--readonly`: Login with read-only access (calls `/backplane/login/{clusterId}?readonly=true`) -- `--multi` or `-m`: Enable multi-cluster login -- `--pd `: Login using PagerDuty incident ID -- `--ohss `: Login using JIRA ID -- `--manager`: Login to the management cluster -- `--service`: Login to the service cluster -- `--cluster-info`: Print cluster information after login -- `--namespace` or `-n`: Set default namespace (default: "default") - -**Example usage:** -``` -AI: I'll login to cluster abc123 for you. -[Uses login tool with clusterId: "abc123"] -Successfully logged in to cluster 'abc123' - -# With readonly access -$ ocm backplane login abc123 --readonly -``` - -#### `console` - -Access the OpenShift web console for a cluster. - -**Parameters:** -- `clusterId` (required): The cluster ID to access console for -- `openInBrowser` (optional): Whether to automatically open console in browser - -**Example usage:** -``` -AI: I'll access the console for cluster abc123. -[Uses console tool] -Successfully accessed cluster console for 'abc123' -🌐 Console opened in default browser -``` - -#### `cluster-resource` - -Execute read-only Kubernetes resource operations on cluster resources. - -**Parameters:** -- `action` (required): Operation to perform (`get`, `describe`, `logs`, `top`, `explain`) -- `resourceType` (optional): Kubernetes resource type (pod, service, deployment, etc.) -- `resourceName` (optional): Specific resource name -- `namespace` (optional): Kubernetes namespace (use 'all' for all namespaces) -- `outputFormat` (optional): Output format (yaml, json, wide, name) -- `labelSelector` (optional): Label selector filter (e.g., 'app=myapp') -- `fieldSelector` (optional): Field selector filter (e.g., 'status.phase=Running') -- `allNamespaces` (optional): Search across all namespaces -- `follow`, `previous`, `tail`, `container` (optional): Logging-specific options - -**Example usage:** -``` -You: "Show me all pods in the kube-system namespace" -AI: I'll get the pods from kube-system. -[Uses cluster-resource tool: action=get, resourceType=pod, namespace=kube-system] - -You: "Get logs for pod xyz-123" -AI: I'll retrieve the logs for that pod. -[Uses cluster-resource tool: action=logs, resourceType=pod, resourceName=xyz-123] - -You: "Describe the deployment myapp" -AI: I'll describe the myapp deployment. -[Uses cluster-resource tool: action=describe, resourceType=deployment, resourceName=myapp] -``` - -#### `cloud-console` - -Get cloud provider console access for a cluster with temporary credentials. - -**Parameters:** -- `clusterId` (required): The cluster ID to get cloud console access for -- `openInBrowser` (optional): Whether to automatically open the cloud console URL in browser -- `output` (optional): Output format (`text` or `json`, defaults to `json`) -- `url` (optional): Override backplane API URL - -**Example usage:** -``` -You: "Get cloud console access for cluster xyz789" - -AI: I'll get cloud console access for cluster xyz789. - -[Uses cloud-console tool with clusterId: "xyz789"] - -Successfully retrieved cloud console access for cluster 'xyz789' -🌐 Cloud console opened in default browser -šŸ“‹ Output format: json -``` - -### Security Considerations - -- All MCP tools provide read-only access to cluster information -- The cluster-resource tool only supports safe, read-only operations (get, describe, logs, top, explain) -- The cloud-console tool provides temporary cloud provider access for debugging and troubleshooting -- Write operations (create, update, delete, patch, apply) are not supported for security -- The server runs with the same permissions as your backplane CLI session -- Cloud console access uses temporary credentials and follows the same security model as the CLI diff --git a/cmd/ocm-backplane/mcp/mcp.go b/cmd/ocm-backplane/mcp/mcp.go deleted file mode 100644 index 8775badf..00000000 --- a/cmd/ocm-backplane/mcp/mcp.go +++ /dev/null @@ -1,102 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - "net/http" - "time" - - "github.com/spf13/cobra" - - "github.com/modelcontextprotocol/go-sdk/mcp" - mcptools "github.com/openshift/backplane-cli/pkg/ai/mcp" -) - -// MCPCmd represents the mcp command -var MCPCmd = &cobra.Command{ - Use: "mcp", - Short: "Start Model Context Protocol server", - Long: `Start a Model Context Protocol (MCP) server that provides access to backplane resources and functionality. - -The MCP server allows AI assistants to interact with backplane clusters, retrieve status information, -and perform operations through the Model Context Protocol standard.`, - Args: cobra.ExactArgs(0), - RunE: runMCP, - SilenceUsage: true, -} - -func init() { - MCPCmd.Flags().Bool("http", false, "Run MCP server over HTTP instead of stdio") - MCPCmd.Flags().Int("port", 8080, "Port to run HTTP server on (only used with --http)") -} - -func runMCP(cmd *cobra.Command, argv []string) error { - // Get flag values - useHTTP, _ := cmd.Flags().GetBool("http") - port, _ := cmd.Flags().GetInt("port") - - // Create a server with backplane tools. - server := mcp.NewServer(&mcp.Implementation{Name: "backplane", Version: "v1.0.0"}, nil) - - // Add the info tool - mcp.AddTool(server, &mcp.Tool{ - Name: "info", - Description: "Get information about the current backplane CLI installation, configuration", - }, mcptools.GetBackplaneInfo) - - // Add the login tool - mcp.AddTool(server, &mcp.Tool{ - Name: "login", - Description: "Login to cluster via backplane", - }, mcptools.BackplaneLogin) - - // Add the console tool - mcp.AddTool(server, &mcp.Tool{ - Name: "console", - Description: "Start OpenShift web console for a cluster. Automatically opens in browser. Console runs in background.", - }, mcptools.BackplaneConsole) - - // Add the cluster resource tool - mcp.AddTool(server, &mcp.Tool{ - Name: "cluster-resource", - Description: "Execute read-only Kubernetes resource operations (get, describe, logs, top, explain) on cluster resources", - }, mcptools.BackplaneClusterResource) - - // Add the cloud console tool - mcp.AddTool(server, &mcp.Tool{ - Name: "cloud-console", - Description: "Get cloud provider console access for a cluster. Automatically opens in browser with temporary credentials. Runs in background.", - }, mcptools.BackplaneCloudConsole) - - // Choose transport method based on flags - if useHTTP { - // Run the server over HTTP using StreamableHTTPHandler - addr := fmt.Sprintf(":%d", port) - fmt.Printf("Starting MCP server on HTTP at http://localhost%s\n", addr) - - // Create HTTP handler that returns our server - handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { - return server - }, nil) - - httpServer := &http.Server{ - Addr: addr, - Handler: handler, - ReadHeaderTimeout: 10 * time.Second, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, - IdleTimeout: 60 * time.Second, - } - - if err := httpServer.ListenAndServe(); err != nil { - return fmt.Errorf("HTTP server error: %w", err) - } - } else { - // Run the server over stdin/stdout, until the client disconnects. - if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { - return err - } - } - - return nil -} diff --git a/cmd/ocm-backplane/mcp/mcp_suite_test.go b/cmd/ocm-backplane/mcp/mcp_suite_test.go deleted file mode 100644 index 6f1fd01f..00000000 --- a/cmd/ocm-backplane/mcp/mcp_suite_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package mcp_test - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestMCP(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "MCP Command Suite") -} diff --git a/cmd/ocm-backplane/mcp/mcp_test.go b/cmd/ocm-backplane/mcp/mcp_test.go deleted file mode 100644 index 5d08492c..00000000 --- a/cmd/ocm-backplane/mcp/mcp_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package mcp_test - -import ( - "context" - "os" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/spf13/cobra" - - mcpCmd "github.com/openshift/backplane-cli/cmd/ocm-backplane/mcp" -) - -var _ = Describe("MCP Command", func() { - Context("Command configuration", func() { - It("Should have correct command properties", func() { - cmd := mcpCmd.MCPCmd - - Expect(cmd.Use).To(Equal("mcp")) - Expect(cmd.Short).To(Equal("Start Model Context Protocol server")) - Expect(cmd.Long).To(ContainSubstring("Start a Model Context Protocol (MCP) server")) - Expect(cmd.Long).To(ContainSubstring("backplane resources and functionality")) - Expect(cmd.Args).ToNot(BeNil()) - Expect(cmd.RunE).ToNot(BeNil()) - Expect(cmd.SilenceUsage).To(BeTrue()) - }) - - It("Should accept no arguments", func() { - cmd := mcpCmd.MCPCmd - - // Test that the command accepts exactly 0 arguments - err := cmd.Args(cmd, []string{}) - Expect(err).To(BeNil()) - - // Test that the command rejects arguments - err = cmd.Args(cmd, []string{"arg1"}) - Expect(err).ToNot(BeNil()) - }) - - It("Should have help available", func() { - cmd := mcpCmd.MCPCmd - err := cmd.Help() - Expect(err).To(BeNil()) - }) - }) - - Context("Command execution", func() { - var ( - originalStdout *os.File - ) - - BeforeEach(func() { - originalStdout = os.Stdout - }) - - AfterEach(func() { - os.Stdout = originalStdout - }) - - It("Should have a RunE function that can be called", func() { - cmd := mcpCmd.MCPCmd - Expect(cmd.RunE).ToNot(BeNil()) - - // We can't easily test the actual execution since it starts a server - // that would block, but we can verify the function signature - runE := cmd.RunE - Expect(runE).To(BeAssignableToTypeOf(func(*cobra.Command, []string) error { return nil })) - }) - }) - - Context("Integration with parent commands", func() { - It("Should be able to be added as a subcommand", func() { - rootCmd := &cobra.Command{Use: "test"} - rootCmd.AddCommand(mcpCmd.MCPCmd) - - // Verify the command was added - subCommands := rootCmd.Commands() - var foundMCP bool - for _, cmd := range subCommands { - if cmd.Use == "mcp" { - foundMCP = true - break - } - } - Expect(foundMCP).To(BeTrue()) - }) - }) - - Context("Command validation", func() { - It("Should have optional flags for HTTP transport", func() { - cmd := mcpCmd.MCPCmd - - // Verify the command has the expected flags - httpFlag := cmd.Flags().Lookup("http") - Expect(httpFlag).ToNot(BeNil()) - Expect(httpFlag.Value.String()).To(Equal("false")) // default value - - portFlag := cmd.Flags().Lookup("port") - Expect(portFlag).ToNot(BeNil()) - Expect(portFlag.Value.String()).To(Equal("8080")) // default value - - // Verify no flags are required (both flags should be optional) - Expect(httpFlag.DefValue).To(Equal("false")) - Expect(portFlag.DefValue).To(Equal("8080")) - }) - - It("Should be runnable without prerequisites", func() { - cmd := mcpCmd.MCPCmd - - // Verify the command can be prepared for execution - cmd.SetArgs([]string{}) - err := cmd.ValidateArgs([]string{}) - Expect(err).To(BeNil()) - }) - - It("Should accept HTTP flag and port flag", func() { - cmd := mcpCmd.MCPCmd - - // Test with HTTP flag - cmd.SetArgs([]string{"--http"}) - err := cmd.ValidateArgs([]string{}) - Expect(err).To(BeNil()) - - // Test with both HTTP and port flags - cmd.SetArgs([]string{"--http", "--port", "9090"}) - err = cmd.ValidateArgs([]string{}) - Expect(err).To(BeNil()) - }) - - It("Should handle port flag independently", func() { - cmd := mcpCmd.MCPCmd - - // Test port flag without HTTP (should still be valid) - cmd.SetArgs([]string{"--port", "3000"}) - err := cmd.ValidateArgs([]string{}) - Expect(err).To(BeNil()) - }) - }) - - Context("Error handling", func() { - It("Should handle context cancellation gracefully", func() { - // Create a cancelled context - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - // Verify we can create a command with the cancelled context - // (This tests that the command setup doesn't panic with cancelled contexts) - cmd := mcpCmd.MCPCmd - cmd.SetContext(ctx) - Expect(cmd.Context()).ToNot(BeNil()) - }) - }) - - Context("MCP Tool Integration", func() { - It("Should have login and info tools properly registered", func() { - // This test verifies that both tools are available through MCP - // We can't easily test the full MCP server without starting it, - // but we can verify the command structure includes the tool setup - - cmd := mcpCmd.MCPCmd - - // Verify the command has a RunE function (which sets up tools) - Expect(cmd.RunE).ToNot(BeNil()) - - // The actual tool registration happens in runMCP function - // We can verify this indirectly by checking the function exists - // and doesn't panic when called with invalid args (which it handles gracefully) - }) - - It("Should handle MCP server startup configuration", func() { - cmd := mcpCmd.MCPCmd - - // Test that command accepts the right combination of flags for tools - cmd.SetArgs([]string{"--http", "--port", "8080"}) - err := cmd.ValidateArgs([]string{}) - Expect(err).To(BeNil()) - - // Reset args for other tests - cmd.SetArgs([]string{}) - }) - - It("Should verify tool names are correctly configured", func() { - // This tests our expectation that the tools have the correct names - // The actual tool names are "info" and "login" (not prefixed) - // When accessed through MCP clients, they become "backplane__info" and "backplane__login" - - cmd := mcpCmd.MCPCmd - - // Verify command can be executed (tools are registered in runMCP) - Expect(cmd.RunE).ToNot(BeNil()) - Expect(cmd.Use).To(Equal("mcp")) - - // The server name is "backplane" which combines with tool names - // This creates the final MCP tool names: "backplane__info" and "backplane__login" - }) - }) -}) diff --git a/cmd/ocm-backplane/root.go b/cmd/ocm-backplane/root.go index af677eec..7d338308 100644 --- a/cmd/ocm-backplane/root.go +++ b/cmd/ocm-backplane/root.go @@ -31,7 +31,6 @@ import ( "github.com/openshift/backplane-cli/cmd/ocm-backplane/login" "github.com/openshift/backplane-cli/cmd/ocm-backplane/logout" managedjob "github.com/openshift/backplane-cli/cmd/ocm-backplane/managedJob" - "github.com/openshift/backplane-cli/cmd/ocm-backplane/mcp" "github.com/openshift/backplane-cli/cmd/ocm-backplane/monitoring" "github.com/openshift/backplane-cli/cmd/ocm-backplane/remediation" "github.com/openshift/backplane-cli/cmd/ocm-backplane/script" @@ -76,7 +75,6 @@ func init() { rootCmd.AddCommand(login.LoginCmd) rootCmd.AddCommand(logout.LogoutCmd) rootCmd.AddCommand(managedjob.NewManagedJobCmd()) - rootCmd.AddCommand(mcp.MCPCmd) rootCmd.AddCommand(script.NewScriptCmd()) rootCmd.AddCommand(status.StatusCmd) rootCmd.AddCommand(session.NewCmdSession()) diff --git a/go.mod b/go.mod index 6af14659..0552040d 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/mitchellh/go-homedir v1.1.0 - github.com/modelcontextprotocol/go-sdk v1.6.0 github.com/olekukonko/tablewriter v0.0.5 github.com/onsi/ginkgo/v2 v2.28.3 github.com/onsi/gomega v1.40.0 @@ -44,7 +43,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/oapi-codegen/runtime v1.1.2 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect @@ -53,9 +51,6 @@ require ( github.com/openshift-online/ocm-api-model/model v0.0.454 // indirect github.com/openshift-online/ocm-common v0.0.29 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/segmentio/asm v1.1.3 // indirect - github.com/segmentio/encoding v0.5.4 // indirect - github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.35.0 // indirect diff --git a/go.sum b/go.sum index bac0eb17..23afc5bc 100644 --- a/go.sum +++ b/go.sum @@ -188,8 +188,6 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= @@ -245,8 +243,6 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= -github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -345,8 +341,6 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/moby/term v0.0.0-20221205130635-1aeaba878587 h1:HfkjXDfhgVaN5rmueG8cL8KKeFNecRCXFhaJ2qZ5SKA= github.com/moby/term v0.0.0-20221205130635-1aeaba878587/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= -github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY= -github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -438,10 +432,6 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= -github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= -github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= -github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -497,8 +487,6 @@ github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4d github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= -github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= -github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/pkg/ai/mcp/backplane_cloud_console.go b/pkg/ai/mcp/backplane_cloud_console.go deleted file mode 100644 index abf5e9c1..00000000 --- a/pkg/ai/mcp/backplane_cloud_console.go +++ /dev/null @@ -1,86 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/openshift/backplane-cli/cmd/ocm-backplane/cloud" -) - -type BackplaneCloudConsoleArgs struct { - ClusterID string `json:"clusterId" jsonschema:"description:the cluster ID for backplane cloud console"` -} - -func BackplaneCloudConsole(ctx context.Context, request *mcp.CallToolRequest, input BackplaneCloudConsoleArgs) (*mcp.CallToolResult, any, error) { - clusterID := strings.TrimSpace(input.ClusterID) - if clusterID == "" { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: "Error: Cluster ID is required for backplane cloud console"}, - }, - }, nil, fmt.Errorf("cluster ID cannot be empty") - } - - // Create cloud console command and configure it - consoleCmd := cloud.ConsoleCmd - - // Set up command arguments - args := []string{clusterID} - consoleCmd.SetArgs(args) - - // Always open in browser when using MCP - err := consoleCmd.Flags().Set("browser", "true") - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Error setting browser flag: %v", err)}, - }, - }, nil, nil - } - - // Set output format to json for better parsing - err = consoleCmd.Flags().Set("output", "json") - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Error setting output flag: %v", err)}, - }, - }, nil, nil - } - - // Run the cloud console command in a background goroutine to avoid blocking - errChan := make(chan error, 1) - go func() { - errChan <- consoleCmd.RunE(consoleCmd, args) - }() - - // Wait briefly to see if there's an immediate error (e.g., login required, invalid cluster) - select { - case err := <-errChan: - // Command failed quickly - likely a configuration/validation error - errorMessage := fmt.Sprintf("Failed to get cloud console for cluster '%s'. Error: %v", clusterID, err) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: errorMessage}, - }, - }, nil, nil - case <-time.After(3 * time.Second): - // No immediate error - cloud console is starting up successfully - // The goroutine continues running in the background - } - - // Build success message - var successMessage strings.Builder - successMessage.WriteString(fmt.Sprintf("āœ… Cloud console access retrieved for cluster '%s'\n\n", clusterID)) - successMessage.WriteString("🌐 Cloud console will open in your default browser when ready\n") - successMessage.WriteString("\nāš ļø Note: The cloud console command is running in the background") - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: successMessage.String()}, - }, - }, nil, nil -} diff --git a/pkg/ai/mcp/backplane_cloud_console_test.go b/pkg/ai/mcp/backplane_cloud_console_test.go deleted file mode 100644 index fb443fe6..00000000 --- a/pkg/ai/mcp/backplane_cloud_console_test.go +++ /dev/null @@ -1,449 +0,0 @@ -package mcp_test - -import ( - "context" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/modelcontextprotocol/go-sdk/mcp" - mcptools "github.com/openshift/backplane-cli/pkg/ai/mcp" -) - -var _ = Describe("BackplaneCloudConsole", func() { - - Context("Input validation", func() { - It("Should reject empty cluster ID", func() { - input := mcptools.BackplaneCloudConsoleArgs{ClusterID: ""} - - result, _, err := mcptools.BackplaneCloudConsole(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("cluster ID cannot be empty")) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(Equal("Error: Cluster ID is required for backplane cloud console")) - }) - - It("Should reject whitespace-only cluster ID", func() { - input := mcptools.BackplaneCloudConsoleArgs{ClusterID: " \t\n "} - - result, _, err := mcptools.BackplaneCloudConsole(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("cluster ID cannot be empty")) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(Equal("Error: Cluster ID is required for backplane cloud console")) - }) - - It("Should trim whitespace from valid cluster ID", func() { - input := mcptools.BackplaneCloudConsoleArgs{ClusterID: " cluster-123 "} - - // Note: This test will try to actually access cloud console command - // We expect it to fail with authentication/configuration errors, but the cluster ID should be trimmed - result, _, err := mcptools.BackplaneCloudConsole(context.Background(), &mcp.CallToolRequest{}, input) - - // The function should not panic and should handle errors gracefully - Expect(err).To(BeNil()) // MCP wrapper handles errors gracefully - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - // Should mention the trimmed cluster ID in the response - Expect(textContent.Text).To(ContainSubstring("cluster-123")) - }) - }) - - Context("Argument structure validation", func() { - It("Should accept valid BackplaneCloudConsoleArgs structure", func() { - input := mcptools.BackplaneCloudConsoleArgs{ - ClusterID: "test-cluster", - } - - // Verify struct fields are accessible - Expect(input.ClusterID).To(Equal("test-cluster")) - }) - - It("Should validate JSON schema tags are present", func() { - // Test that the struct works with JSON marshaling/unmarshaling - // This ensures MCP can generate proper schemas - input := mcptools.BackplaneCloudConsoleArgs{ - ClusterID: "schema-test", - } - - Expect(input.ClusterID).To(Equal("schema-test")) - - // The struct should have proper JSON tags for MCP integration - // We can't easily test the tags at runtime, but this test documents the requirement - }) - }) - - Context("Cluster ID handling", func() { - It("Should handle different cluster ID formats", func() { - clusterIDs := []string{ - "cluster-1", - "cluster-with-dashes", - "cluster_with_underscores", - } - - for _, clusterID := range clusterIDs { - input := mcptools.BackplaneCloudConsoleArgs{ - ClusterID: clusterID, - } - - // Verify the cluster ID is set correctly - Expect(input.ClusterID).To(Equal(clusterID)) - } - }) - - It("Should handle different cluster IDs", func() { - testCases := []string{ - "test-cluster-1", - "test-cluster-2", - "test-cluster-3", - } - - for _, clusterID := range testCases { - input := mcptools.BackplaneCloudConsoleArgs{ - ClusterID: clusterID, - } - - // Verify struct configuration - Expect(input.ClusterID).To(Equal(clusterID)) - } - }) - - It("Should handle different cluster IDs", func() { - clusterIDs := []string{ - "comprehensive-test-cluster", - "another-test-cluster", - "yet-another-cluster", - } - - for _, clusterID := range clusterIDs { - input := mcptools.BackplaneCloudConsoleArgs{ - ClusterID: clusterID, - } - - // All parameters should be accessible - Expect(input.ClusterID).To(Equal(clusterID)) - } - }) - }) - - Context("Response format validation", func() { - It("Should return valid MCP response structure for validation errors", func() { - input := mcptools.BackplaneCloudConsoleArgs{ClusterID: ""} // Invalid input - - result, output, err := mcptools.BackplaneCloudConsole(context.Background(), &mcp.CallToolRequest{}, input) - - // Verify MCP response structure for validation errors - Expect(err).ToNot(BeNil()) // Input validation error - Expect(result).ToNot(BeNil()) - Expect(result.Content).To(HaveLen(1)) - - // Verify output structure (should be nil) - Expect(output).To(BeNil()) - - // Verify content type - textContent, ok := result.Content[0].(*mcp.TextContent) - Expect(ok).To(BeTrue()) - Expect(textContent.Text).ToNot(BeEmpty()) - }) - - It("Should include cluster ID in all response messages", func() { - testClusterID := "response-test-cluster" - input := mcptools.BackplaneCloudConsoleArgs{ClusterID: testClusterID} - - result, _, err := mcptools.BackplaneCloudConsole(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(ContainSubstring(testClusterID)) - }) - - It("Should indicate browser behavior in response", func() { - input := mcptools.BackplaneCloudConsoleArgs{ - ClusterID: "browser-response-test", - } - - result, _, err := mcptools.BackplaneCloudConsole(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - // Response should indicate browser behavior (either success or in error message) - responseText := textContent.Text - containsBrowserRef := strings.Contains(responseText, "browser") || - strings.Contains(responseText, "Browser") || - strings.Contains(responseText, "🌐") - Expect(containsBrowserRef).To(BeTrue()) - }) - - It("Should validate cluster ID parameter handling", func() { - clusterIDs := []string{"cluster-test-1", "cluster-test-2", "cluster-test-3"} - - for _, clusterID := range clusterIDs { - input := mcptools.BackplaneCloudConsoleArgs{ - ClusterID: clusterID, - } - - // Test struct field access - Expect(input.ClusterID).To(Equal(clusterID), "Test case: "+clusterID) - - result, _, err := mcptools.BackplaneCloudConsole(context.Background(), &mcp.CallToolRequest{}, input) - - // Should handle gracefully (may fail due to cluster not existing) - Expect(err).To(BeNil(), "Test case: "+clusterID) - Expect(result).ToNot(BeNil(), "Test case: "+clusterID) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).ToNot(BeEmpty(), "Test case: "+clusterID) - // Response should mention the cluster ID regardless of success/failure - Expect(textContent.Text).To(ContainSubstring(clusterID), "Test case: "+clusterID) - } - }) - }) - - Context("Edge cases", func() { - It("Should handle various cluster ID formats", func() { - testCases := []string{ - "simple-cluster", - "cluster-with-dashes", - "cluster_with_underscores", - "cluster.with.dots", - "cluster123numbers", - "UPPERCASE-CLUSTER", - "mixed-Case_Cluster.123", - "very-long-cluster-name-with-multiple-parts", - } - - for _, clusterID := range testCases { - input := mcptools.BackplaneCloudConsoleArgs{ClusterID: clusterID} - - // Should handle all formats without panicking - result, _, err := mcptools.BackplaneCloudConsole(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil(), "Test case: "+clusterID) - Expect(result).ToNot(BeNil(), "Test case: "+clusterID) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(ContainSubstring(clusterID), "Test case: "+clusterID) - } - }) - - It("Should handle different cluster IDs for testing", func() { - clusterIDs := []string{ - "cluster-test-1", - "cluster-test-2", - "cluster-test-3", - } - - for _, clusterID := range clusterIDs { - input := mcptools.BackplaneCloudConsoleArgs{ - ClusterID: clusterID, - } - - // Test struct field access - Expect(input.ClusterID).To(Equal(clusterID)) - } - }) - - It("Should handle context cancellation gracefully", func() { - // Create a cancelled context - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - input := mcptools.BackplaneCloudConsoleArgs{ClusterID: ""} - - // Input validation should still work with cancelled context - result, _, err := mcptools.BackplaneCloudConsole(ctx, &mcp.CallToolRequest{}, input) - - Expect(err).ToNot(BeNil()) // Should reject empty cluster ID - Expect(err.Error()).To(ContainSubstring("cluster ID cannot be empty")) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(Equal("Error: Cluster ID is required for backplane cloud console")) - }) - }) - - Context("Parameter combinations", func() { - It("Should handle cluster ID parameter", func() { - input := mcptools.BackplaneCloudConsoleArgs{ - ClusterID: "minimal-test", - } - - // Verify cluster ID - Expect(input.ClusterID).To(Equal("minimal-test")) - - // Should pass basic validation - trimmedID := strings.TrimSpace(input.ClusterID) - Expect(trimmedID).ToNot(BeEmpty()) - }) - - It("Should handle browser flag with different output formats", func() { - testCases := []struct { - output string - browser bool - description string - }{ - {"text", true, "text format with browser"}, - {"json", true, "json format with browser"}, - {"yaml", false, "yaml format without browser"}, - {"", true, "default format with browser"}, - } - - for _, tc := range testCases { - input := mcptools.BackplaneCloudConsoleArgs{ - ClusterID: "combo-test-" + tc.description, - } - - Expect(input.ClusterID).To(ContainSubstring("combo-test"), tc.description) - } - }) - }) - - Context("MCP protocol compliance", func() { - It("Should return proper MCP response structure", func() { - input := mcptools.BackplaneCloudConsoleArgs{ClusterID: "mcp-protocol-test"} - - result, output, err := mcptools.BackplaneCloudConsole(context.Background(), &mcp.CallToolRequest{}, input) - - // Verify response structure (even if cloud console command fails) - Expect(err).To(BeNil()) // MCP wrapper handles errors gracefully - Expect(result).ToNot(BeNil()) - Expect(result.Content).To(HaveLen(1)) - - // Verify output structure (should be nil) - Expect(output).To(BeNil()) - - // Verify content type - textContent, ok := result.Content[0].(*mcp.TextContent) - Expect(ok).To(BeTrue()) - Expect(textContent.Text).ToNot(BeEmpty()) - }) - - It("Should have proper JSON schema structure for MCP integration", func() { - // Test the input argument structure for MCP compatibility - input := mcptools.BackplaneCloudConsoleArgs{ - ClusterID: "schema-validation-test", - } - - // Verify all fields are accessible and properly typed - Expect(input.ClusterID).To(BeAssignableToTypeOf("")) - - // The struct should work with MCP's JSON schema generation - Expect(input.ClusterID).To(Equal("schema-validation-test")) - }) - }) - - Context("Integration behavior", func() { - It("Should create cloud console command instance for each call", func() { - // Test that each call handles different parameters independently - input1 := mcptools.BackplaneCloudConsoleArgs{ClusterID: "call-1"} - input2 := mcptools.BackplaneCloudConsoleArgs{ClusterID: "call-2"} - - // First call - result1, _, err1 := mcptools.BackplaneCloudConsole(context.Background(), &mcp.CallToolRequest{}, input1) - Expect(err1).To(BeNil()) - Expect(result1).ToNot(BeNil()) - - // Second call - result2, _, err2 := mcptools.BackplaneCloudConsole(context.Background(), &mcp.CallToolRequest{}, input2) - Expect(err2).To(BeNil()) - Expect(result2).ToNot(BeNil()) - - // Both calls should produce valid responses - textContent1 := result1.Content[0].(*mcp.TextContent) - textContent2 := result2.Content[0].(*mcp.TextContent) - - Expect(textContent1.Text).To(ContainSubstring("call-1")) - Expect(textContent2.Text).To(ContainSubstring("call-2")) - - // Both responses should be well-formed (success or error) - Expect(textContent1.Text).ToNot(BeEmpty()) - Expect(textContent2.Text).ToNot(BeEmpty()) - }) - - It("Should properly handle direct cloud console command integration", func() { - // This test verifies that we're calling cloud.ConsoleCmd.RunE - // rather than using external command execution - - input := mcptools.BackplaneCloudConsoleArgs{ClusterID: "integration-direct-test"} - - result, _, err := mcptools.BackplaneCloudConsole(context.Background(), &mcp.CallToolRequest{}, input) - - // The function should complete (though cloud console command may fail due to test environment) - Expect(err).To(BeNil()) // MCP wrapper handles errors gracefully - Expect(result).ToNot(BeNil()) - Expect(result.Content).To(HaveLen(1)) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).ToNot(BeEmpty()) - Expect(textContent.Text).To(ContainSubstring("integration-direct-test")) - }) - - It("Should handle realistic cloud console scenarios", func() { - scenarios := []struct { - name string - clusterID string - browser bool - output string - url string - }{ - { - name: "Production cluster with browser", - clusterID: "prod-cluster-123", - browser: true, - output: "json", - }, - { - name: "Staging cluster no browser", - clusterID: "staging-cluster-456", - browser: false, - output: "text", - }, - { - name: "Dev cluster with custom URL", - clusterID: "dev-cluster-789", - browser: false, - output: "json", - url: "https://dev.backplane.example.com", - }, - } - - for _, scenario := range scenarios { - input := mcptools.BackplaneCloudConsoleArgs{ - ClusterID: scenario.clusterID, - } - - result, _, err := mcptools.BackplaneCloudConsole(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil(), "Scenario: "+scenario.name) - Expect(result).ToNot(BeNil(), "Scenario: "+scenario.name) - - textContent := result.Content[0].(*mcp.TextContent) - // Should always include the cluster ID in response (success or error) - Expect(textContent.Text).To(ContainSubstring(scenario.clusterID), - "Scenario: "+scenario.name+" should contain cluster ID") - - // Response should be well-formed - Expect(textContent.Text).ToNot(BeEmpty(), "Scenario: "+scenario.name) - } - }) - }) - - // Note: We don't test actual cloud console command execution in unit tests - // because it requires authentication, network access, and valid cluster credentials. - // The cloud console integration is tested through the direct function call approach, - // ensuring we use cloud.ConsoleCmd.RunE instead of external command execution. - // Integration testing with actual cloud console functionality should be - // done in separate integration test suites with proper authentication setup. -}) diff --git a/pkg/ai/mcp/backplane_cluster_resource.go b/pkg/ai/mcp/backplane_cluster_resource.go deleted file mode 100644 index f3f07c18..00000000 --- a/pkg/ai/mcp/backplane_cluster_resource.go +++ /dev/null @@ -1,277 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - "os/exec" - "strings" - - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -type BackplaneClusterResourceArgs struct { - Action string `json:"action" jsonschema:"description:oc read action to perform (get, describe, logs, top, explain)"` - ResourceType string `json:"resourceType,omitempty" jsonschema:"description:kubernetes resource type (pod, service, deployment, configmap, secret, ingress, pvc, node, etc.)"` - ResourceName string `json:"resourceName,omitempty" jsonschema:"description:specific resource name (optional for get/describe actions)"` - Namespace string `json:"namespace,omitempty" jsonschema:"description:kubernetes namespace (use 'all' for all namespaces)"` - OutputFormat string `json:"outputFormat,omitempty" jsonschema:"description:output format (yaml, json, wide, name, custom-columns)"` - LabelSelector string `json:"labelSelector,omitempty" jsonschema:"description:label selector filter (e.g., 'app=myapp,version=v1')"` - FieldSelector string `json:"fieldSelector,omitempty" jsonschema:"description:field selector filter (e.g., 'status.phase=Running')"` - AllNamespaces bool `json:"allNamespaces,omitempty" jsonschema:"description:search across all namespaces"` - Follow bool `json:"follow,omitempty" jsonschema:"description:follow logs in real-time (for logs action)"` - Previous bool `json:"previous,omitempty" jsonschema:"description:get previous container logs (for logs action)"` - Tail int `json:"tail,omitempty" jsonschema:"description:number of recent log lines to show (for logs action)"` - Container string `json:"container,omitempty" jsonschema:"description:container name for multi-container pods (for logs/exec actions)"` - ShowLabels bool `json:"showLabels,omitempty" jsonschema:"description:show labels in output"` - SortBy string `json:"sortBy,omitempty" jsonschema:"description:sort output by field (e.g., '.metadata.creationTimestamp')"` - Watch bool `json:"watch,omitempty" jsonschema:"description:watch for changes after listing/getting objects"` - Raw string `json:"raw,omitempty" jsonschema:"description:raw oc read arguments to pass directly"` -} - -func BackplaneClusterResource(ctx context.Context, request *mcp.CallToolRequest, input BackplaneClusterResourceArgs) (*mcp.CallToolResult, any, error) { - action := strings.TrimSpace(input.Action) - if action == "" { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: "Error: Action is required for cluster resource operations"}, - }, - }, nil, fmt.Errorf("action cannot be empty") - } - - // Validate that only read actions are allowed - allowedActions := []string{"get", "describe", "logs", "top", "explain"} - actionLower := strings.ToLower(action) - isAllowed := false - for _, allowed := range allowedActions { - if actionLower == allowed { - isAllowed = true - break - } - } - if !isAllowed { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Error: Action '%s' is not allowed. Only read actions are supported: %s", action, strings.Join(allowedActions, ", "))}, - }, - }, nil, fmt.Errorf("unsupported action: %s", action) - } - - // Build oc command arguments - args := []string{} - - // If raw arguments are provided, use them directly - if strings.TrimSpace(input.Raw) != "" { - rawArgs := strings.Fields(strings.TrimSpace(input.Raw)) - args = append(args, rawArgs...) - } else { - // Build command based on action and parameters - args = buildOcCommand(input) - } - - // Execute the oc command - cmd := exec.CommandContext(ctx, "oc", args...) //nolint:gosec - - // Capture both stdout and stderr - output, err := cmd.CombinedOutput() - outputStr := strings.TrimSpace(string(output)) - - if err != nil { - errorMessage := fmt.Sprintf("Failed to execute oc %s", action) - if strings.TrimSpace(input.ResourceType) != "" { - errorMessage += fmt.Sprintf(" for resource type '%s'", strings.TrimSpace(input.ResourceType)) - } - if strings.TrimSpace(input.ResourceName) != "" { - errorMessage += fmt.Sprintf(" (resource: %s)", strings.TrimSpace(input.ResourceName)) - } - if strings.TrimSpace(input.Namespace) != "" { - errorMessage += fmt.Sprintf(" in namespace '%s'", strings.TrimSpace(input.Namespace)) - } - errorMessage += fmt.Sprintf(". Error: %v", err) - - if outputStr != "" { - errorMessage += fmt.Sprintf("\nCommand output: %s", outputStr) - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: errorMessage}, - }, - }, nil, nil // Return nil error since we're handling it gracefully - } - - // Build success message based on action - var successMessage strings.Builder - switch strings.ToLower(action) { - case "get": - if strings.TrimSpace(input.ResourceName) != "" { - successMessage.WriteString(fmt.Sprintf("šŸ“‹ Resource '%s/%s'", strings.TrimSpace(input.ResourceType), strings.TrimSpace(input.ResourceName))) - } else { - successMessage.WriteString(fmt.Sprintf("šŸ“‹ Resources of type '%s'", strings.TrimSpace(input.ResourceType))) - } - if strings.TrimSpace(input.Namespace) != "" && strings.TrimSpace(input.Namespace) != "all" { - successMessage.WriteString(fmt.Sprintf(" in namespace '%s'", strings.TrimSpace(input.Namespace))) - } else if input.AllNamespaces || strings.TrimSpace(input.Namespace) == "all" { - successMessage.WriteString(" across all namespaces") - } - successMessage.WriteString(":") - - case "describe": - if strings.TrimSpace(input.ResourceName) != "" { - successMessage.WriteString(fmt.Sprintf("šŸ“ Detailed description of '%s/%s'", strings.TrimSpace(input.ResourceType), strings.TrimSpace(input.ResourceName))) - } else { - successMessage.WriteString(fmt.Sprintf("šŸ“ Detailed description of '%s' resources", strings.TrimSpace(input.ResourceType))) - } - if strings.TrimSpace(input.Namespace) != "" && strings.TrimSpace(input.Namespace) != "all" { - successMessage.WriteString(fmt.Sprintf(" in namespace '%s'", strings.TrimSpace(input.Namespace))) - } - successMessage.WriteString(":") - - case "logs": - successMessage.WriteString(fmt.Sprintf("šŸ“„ Logs from '%s'", strings.TrimSpace(input.ResourceName))) - if strings.TrimSpace(input.Container) != "" { - successMessage.WriteString(fmt.Sprintf(" (container: %s)", strings.TrimSpace(input.Container))) - } - if strings.TrimSpace(input.Namespace) != "" { - successMessage.WriteString(fmt.Sprintf(" in namespace '%s'", strings.TrimSpace(input.Namespace))) - } - if input.Follow { - successMessage.WriteString(" (following)") - } - if input.Previous { - successMessage.WriteString(" (previous container)") - } - successMessage.WriteString(":") - - // Removed delete case as write actions are not supported - - case "top": - successMessage.WriteString(fmt.Sprintf("šŸ“Š Resource usage for '%s'", strings.TrimSpace(input.ResourceType))) - if strings.TrimSpace(input.Namespace) != "" { - successMessage.WriteString(fmt.Sprintf(" in namespace '%s'", strings.TrimSpace(input.Namespace))) - } - successMessage.WriteString(":") - - case "explain": - successMessage.WriteString(fmt.Sprintf("šŸ“š API documentation for '%s':", strings.TrimSpace(input.ResourceType))) - - // Removed apply case as write actions are not supported - - // Removed patch case as write actions are not supported - - // Removed scale case as write actions are not supported - - // Removed edit case as write actions are not supported - - default: - successMessage.WriteString(fmt.Sprintf("āœ… Executed oc %s", action)) - if strings.TrimSpace(input.ResourceType) != "" { - successMessage.WriteString(fmt.Sprintf(" on '%s'", strings.TrimSpace(input.ResourceType))) - } - successMessage.WriteString(":") - } - - if outputStr != "" { - successMessage.WriteString(fmt.Sprintf("\n\n%s", outputStr)) - } else { - successMessage.WriteString("\n\n(No output returned from command)") - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: successMessage.String()}, - }, - }, nil, nil -} - -func buildOcCommand(input BackplaneClusterResourceArgs) []string { - var args []string - - action := strings.ToLower(strings.TrimSpace(input.Action)) - args = append(args, action) - - // Handle different actions (only read actions allowed) - switch action { - case "get", "describe": - if strings.TrimSpace(input.ResourceType) != "" { - args = append(args, strings.TrimSpace(input.ResourceType)) - } - if strings.TrimSpace(input.ResourceName) != "" { - args = append(args, strings.TrimSpace(input.ResourceName)) - } - - case "logs": - if strings.TrimSpace(input.ResourceName) == "" { - // For logs, we need a pod name - args = append(args, "pod") - } else { - args = append(args, strings.TrimSpace(input.ResourceName)) - } - - case "top": - if strings.TrimSpace(input.ResourceType) != "" { - args = append(args, strings.TrimSpace(input.ResourceType)) - } else { - args = append(args, "pods") // Default to pods for top - } - - case "explain": - if strings.TrimSpace(input.ResourceType) != "" { - args = append(args, strings.TrimSpace(input.ResourceType)) - } - - } - - // Add namespace - if strings.TrimSpace(input.Namespace) != "" { - if strings.TrimSpace(input.Namespace) == "all" || input.AllNamespaces { - args = append(args, "--all-namespaces") - } else { - args = append(args, "--namespace", strings.TrimSpace(input.Namespace)) - } - } else if input.AllNamespaces { - args = append(args, "--all-namespaces") - } - - // Add output format - if strings.TrimSpace(input.OutputFormat) != "" { - args = append(args, "-o", strings.TrimSpace(input.OutputFormat)) - } - - // Add selectors - if strings.TrimSpace(input.LabelSelector) != "" { - args = append(args, "-l", strings.TrimSpace(input.LabelSelector)) - } - if strings.TrimSpace(input.FieldSelector) != "" { - args = append(args, "--field-selector", strings.TrimSpace(input.FieldSelector)) - } - - // Add flags based on action (only read actions supported) - switch action { - case "logs": - if input.Follow { - args = append(args, "--follow") - } - if input.Previous { - args = append(args, "--previous") - } - if input.Tail > 0 { - args = append(args, "--tail", fmt.Sprintf("%d", input.Tail)) - } - if strings.TrimSpace(input.Container) != "" { - args = append(args, "-c", strings.TrimSpace(input.Container)) - } - - case "get", "describe": - if input.ShowLabels { - args = append(args, "--show-labels") - } - if strings.TrimSpace(input.SortBy) != "" { - args = append(args, "--sort-by", strings.TrimSpace(input.SortBy)) - } - if input.Watch { - args = append(args, "--watch") - } - } - - return args -} diff --git a/pkg/ai/mcp/backplane_cluster_resource_test.go b/pkg/ai/mcp/backplane_cluster_resource_test.go deleted file mode 100644 index 6b156019..00000000 --- a/pkg/ai/mcp/backplane_cluster_resource_test.go +++ /dev/null @@ -1,578 +0,0 @@ -package mcp_test - -import ( - "context" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/modelcontextprotocol/go-sdk/mcp" - mcptools "github.com/openshift/backplane-cli/pkg/ai/mcp" -) - -var _ = Describe("BackplaneClusterResource", func() { - - Context("Input validation", func() { - It("Should reject empty action", func() { - input := mcptools.BackplaneClusterResourceArgs{Action: ""} - - result, _, err := mcptools.BackplaneClusterResource(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("action cannot be empty")) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(Equal("Error: Action is required for cluster resource operations")) - }) - - It("Should reject whitespace-only action", func() { - input := mcptools.BackplaneClusterResourceArgs{Action: " \t\n "} - - result, _, err := mcptools.BackplaneClusterResource(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("action cannot be empty")) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(Equal("Error: Action is required for cluster resource operations")) - }) - - It("Should reject invalid actions", func() { - invalidActions := []string{"create", "delete", "patch", "apply", "edit", "replace", "scale"} - - for _, action := range invalidActions { - input := mcptools.BackplaneClusterResourceArgs{Action: action} - - result, _, err := mcptools.BackplaneClusterResource(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).ToNot(BeNil(), "Invalid action should be rejected: "+action) - Expect(err.Error()).To(ContainSubstring("unsupported action: "+action), "Test case: "+action) - Expect(result).ToNot(BeNil(), "Test case: "+action) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(ContainSubstring("Action '"+action+"' is not allowed"), "Test case: "+action) - Expect(textContent.Text).To(ContainSubstring("Only read actions are supported"), "Test case: "+action) - } - }) - - It("Should accept valid read-only actions", func() { - validActions := []string{"get", "describe", "logs", "top", "explain"} - - for _, action := range validActions { - input := mcptools.BackplaneClusterResourceArgs{Action: action} - - // Note: This will try to execute oc command and likely fail due to test environment - // But it should pass validation and not error on invalid action - result, _, err := mcptools.BackplaneClusterResource(context.Background(), &mcp.CallToolRequest{}, input) - - // Should not fail on action validation (may fail on oc execution) - Expect(err).To(BeNil(), "Valid action should pass validation: "+action) - Expect(result).ToNot(BeNil(), "Test case: "+action) - - textContent := result.Content[0].(*mcp.TextContent) - // Should not contain "not allowed" message for valid actions - Expect(textContent.Text).ToNot(ContainSubstring("is not allowed"), "Test case: "+action) - } - }) - - It("Should handle case insensitive actions", func() { - caseCombinations := []string{"GET", "Get", "gEt", "DESCRIBE", "Describe"} - - for _, action := range caseCombinations { - input := mcptools.BackplaneClusterResourceArgs{Action: action} - - result, _, err := mcptools.BackplaneClusterResource(context.Background(), &mcp.CallToolRequest{}, input) - - // Should pass validation for case variations - Expect(err).To(BeNil(), "Case insensitive action should work: "+action) - Expect(result).ToNot(BeNil(), "Test case: "+action) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).ToNot(ContainSubstring("is not allowed"), "Test case: "+action) - } - }) - }) - - Context("Argument structure validation", func() { - It("Should accept valid BackplaneClusterResourceArgs structure", func() { - input := mcptools.BackplaneClusterResourceArgs{ - Action: "get", - ResourceType: "pod", - ResourceName: "test-pod", - Namespace: "default", - OutputFormat: "yaml", - LabelSelector: "app=test", - FieldSelector: "status.phase=Running", - AllNamespaces: true, - Follow: false, - Previous: false, - Tail: 100, - Container: "main", - ShowLabels: true, - SortBy: ".metadata.creationTimestamp", - Watch: false, - Raw: "", - } - - // Verify all struct fields are accessible - Expect(input.Action).To(Equal("get")) - Expect(input.ResourceType).To(Equal("pod")) - Expect(input.ResourceName).To(Equal("test-pod")) - Expect(input.Namespace).To(Equal("default")) - Expect(input.OutputFormat).To(Equal("yaml")) - Expect(input.LabelSelector).To(Equal("app=test")) - Expect(input.FieldSelector).To(Equal("status.phase=Running")) - Expect(input.AllNamespaces).To(BeTrue()) - Expect(input.Follow).To(BeFalse()) - Expect(input.Previous).To(BeFalse()) - Expect(input.Tail).To(Equal(100)) - Expect(input.Container).To(Equal("main")) - Expect(input.ShowLabels).To(BeTrue()) - Expect(input.SortBy).To(Equal(".metadata.creationTimestamp")) - Expect(input.Watch).To(BeFalse()) - Expect(input.Raw).To(Equal("")) - }) - - It("Should handle default values correctly", func() { - input := mcptools.BackplaneClusterResourceArgs{Action: "get"} - - // Verify default values - Expect(input.Action).To(Equal("get")) - Expect(input.ResourceType).To(Equal("")) - Expect(input.ResourceName).To(Equal("")) - Expect(input.Namespace).To(Equal("")) - Expect(input.OutputFormat).To(Equal("")) - Expect(input.LabelSelector).To(Equal("")) - Expect(input.FieldSelector).To(Equal("")) - Expect(input.AllNamespaces).To(BeFalse()) - Expect(input.Follow).To(BeFalse()) - Expect(input.Previous).To(BeFalse()) - Expect(input.Tail).To(Equal(0)) - Expect(input.Container).To(Equal("")) - Expect(input.ShowLabels).To(BeFalse()) - Expect(input.SortBy).To(Equal("")) - Expect(input.Watch).To(BeFalse()) - Expect(input.Raw).To(Equal("")) - }) - - It("Should validate JSON schema tags are present", func() { - // Test that the struct works with JSON marshaling/unmarshaling - // This ensures MCP can generate proper schemas - input := mcptools.BackplaneClusterResourceArgs{ - Action: "describe", - ResourceType: "deployment", - ResourceName: "myapp", - Namespace: "production", - } - - Expect(input.Action).To(Equal("describe")) - Expect(input.ResourceType).To(Equal("deployment")) - Expect(input.ResourceName).To(Equal("myapp")) - Expect(input.Namespace).To(Equal("production")) - - // The struct should have proper JSON tags for MCP integration - // We can't easily test the tags at runtime, but this test documents the requirement - }) - }) - - Context("Response format validation", func() { - It("Should return valid MCP response structure for validation errors", func() { - input := mcptools.BackplaneClusterResourceArgs{Action: ""} // Invalid input - - result, output, err := mcptools.BackplaneClusterResource(context.Background(), &mcp.CallToolRequest{}, input) - - // Verify MCP response structure for validation errors - Expect(err).ToNot(BeNil()) // Input validation error - Expect(result).ToNot(BeNil()) - Expect(result.Content).To(HaveLen(1)) - - // Verify output structure (should be nil) - Expect(output).To(BeNil()) - - // Verify content type - textContent, ok := result.Content[0].(*mcp.TextContent) - Expect(ok).To(BeTrue()) - Expect(textContent.Text).ToNot(BeEmpty()) - }) - - It("Should return valid MCP response structure for invalid actions", func() { - input := mcptools.BackplaneClusterResourceArgs{Action: "delete"} // Not allowed - - result, output, err := mcptools.BackplaneClusterResource(context.Background(), &mcp.CallToolRequest{}, input) - - // Verify MCP response structure for action validation errors - Expect(err).ToNot(BeNil()) // Action validation error - Expect(result).ToNot(BeNil()) - Expect(result.Content).To(HaveLen(1)) - - // Verify output structure (should be nil) - Expect(output).To(BeNil()) - - // Verify content type - textContent, ok := result.Content[0].(*mcp.TextContent) - Expect(ok).To(BeTrue()) - Expect(textContent.Text).ToNot(BeEmpty()) - Expect(textContent.Text).To(ContainSubstring("is not allowed")) - }) - }) - - Context("Parameter combinations", func() { - It("Should handle minimal parameters", func() { - input := mcptools.BackplaneClusterResourceArgs{Action: "get"} - - // Should pass validation with minimal parameters - trimmedAction := strings.TrimSpace(input.Action) - Expect(trimmedAction).To(Equal("get")) - Expect(trimmedAction).ToNot(BeEmpty()) - - // Verify action is in allowed list - allowedActions := []string{"get", "describe", "logs", "top", "explain"} - Expect(allowedActions).To(ContainElement("get")) - }) - - It("Should handle get action with resource type", func() { - input := mcptools.BackplaneClusterResourceArgs{ - Action: "get", - ResourceType: "pods", - } - - Expect(input.Action).To(Equal("get")) - Expect(input.ResourceType).To(Equal("pods")) - - // Should pass basic validation - trimmedAction := strings.TrimSpace(input.Action) - Expect(trimmedAction).ToNot(BeEmpty()) - }) - - It("Should handle describe action with specific resource", func() { - input := mcptools.BackplaneClusterResourceArgs{ - Action: "describe", - ResourceType: "deployment", - ResourceName: "myapp", - Namespace: "production", - } - - Expect(input.Action).To(Equal("describe")) - Expect(input.ResourceType).To(Equal("deployment")) - Expect(input.ResourceName).To(Equal("myapp")) - Expect(input.Namespace).To(Equal("production")) - }) - - It("Should handle logs action with logging parameters", func() { - input := mcptools.BackplaneClusterResourceArgs{ - Action: "logs", - ResourceType: "pod", - ResourceName: "test-pod-123", - Namespace: "kube-system", - Follow: true, - Previous: false, - Tail: 50, - Container: "main-container", - } - - Expect(input.Action).To(Equal("logs")) - Expect(input.Follow).To(BeTrue()) - Expect(input.Previous).To(BeFalse()) - Expect(input.Tail).To(Equal(50)) - Expect(input.Container).To(Equal("main-container")) - }) - - It("Should handle get action with advanced filtering", func() { - input := mcptools.BackplaneClusterResourceArgs{ - Action: "get", - ResourceType: "pods", - Namespace: "production", - LabelSelector: "app=myapp,version=v1.0", - FieldSelector: "status.phase=Running", - OutputFormat: "yaml", - ShowLabels: true, - AllNamespaces: false, - Watch: false, - } - - Expect(input.LabelSelector).To(Equal("app=myapp,version=v1.0")) - Expect(input.FieldSelector).To(Equal("status.phase=Running")) - Expect(input.OutputFormat).To(Equal("yaml")) - Expect(input.ShowLabels).To(BeTrue()) - }) - - It("Should handle raw command arguments", func() { - input := mcptools.BackplaneClusterResourceArgs{ - Action: "get", - Raw: "pods --all-namespaces -o wide", - } - - Expect(input.Action).To(Equal("get")) - Expect(input.Raw).To(Equal("pods --all-namespaces -o wide")) - - // Should pass action validation - trimmedAction := strings.TrimSpace(input.Action) - Expect(trimmedAction).ToNot(BeEmpty()) - }) - }) - - Context("Action validation", func() { - It("Should validate all allowed read-only actions", func() { - allowedActions := []string{"get", "describe", "logs", "top", "explain"} - - for _, action := range allowedActions { - input := mcptools.BackplaneClusterResourceArgs{Action: action} - - // Test that validation passes (may fail on execution but not on validation) - result, _, err := mcptools.BackplaneClusterResource(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil(), "Action should be allowed: "+action) - Expect(result).ToNot(BeNil(), "Test case: "+action) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).ToNot(ContainSubstring("is not allowed"), "Test case: "+action) - } - }) - - It("Should reject all write operations", func() { - writeActions := []string{ - "create", "apply", "delete", "patch", "replace", "edit", - "scale", "rollout", "label", "annotate", "expose", "set", - } - - for _, action := range writeActions { - input := mcptools.BackplaneClusterResourceArgs{Action: action} - - result, _, err := mcptools.BackplaneClusterResource(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).ToNot(BeNil(), "Write action should be rejected: "+action) - Expect(err.Error()).To(ContainSubstring("unsupported action"), "Test case: "+action) - Expect(result).ToNot(BeNil(), "Test case: "+action) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(ContainSubstring("is not allowed"), "Test case: "+action) - Expect(textContent.Text).To(ContainSubstring("Only read actions are supported"), "Test case: "+action) - } - }) - - It("Should handle mixed case actions correctly", func() { - mixedCaseActions := []string{"GET", "Get", "gEt", "DESCRIBE", "Describe", "dEsCrIbE"} - - for _, action := range mixedCaseActions { - input := mcptools.BackplaneClusterResourceArgs{Action: action} - - result, _, err := mcptools.BackplaneClusterResource(context.Background(), &mcp.CallToolRequest{}, input) - - // Should pass validation regardless of case - Expect(err).To(BeNil(), "Case variation should be accepted: "+action) - Expect(result).ToNot(BeNil(), "Test case: "+action) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).ToNot(ContainSubstring("is not allowed"), "Test case: "+action) - } - }) - }) - - Context("Edge cases", func() { - It("Should handle various resource type formats", func() { - resourceTypes := []string{ - "pod", "pods", "po", - "service", "services", "svc", - "deployment", "deployments", "deploy", - "configmap", "configmaps", "cm", - "secret", "secrets", - "ingress", "ingresses", "ing", - "persistentvolumeclaim", "persistentvolumeclaims", "pvc", - "node", "nodes", "no", - } - - for _, resourceType := range resourceTypes { - input := mcptools.BackplaneClusterResourceArgs{ - Action: "get", - ResourceType: resourceType, - } - - // Test struct field access - Expect(input.Action).To(Equal("get")) - Expect(input.ResourceType).To(Equal(resourceType)) - - // Should pass basic validation - trimmedAction := strings.TrimSpace(input.Action) - Expect(trimmedAction).ToNot(BeEmpty(), "Test case: "+resourceType) - } - }) - - It("Should handle various namespace formats", func() { - namespaces := []string{ - "default", - "kube-system", - "kube-public", - "openshift-config", - "my-app-namespace", - "test_namespace", - "namespace.with.dots", - "all", // Special case for all namespaces - } - - for _, namespace := range namespaces { - input := mcptools.BackplaneClusterResourceArgs{ - Action: "get", - Namespace: namespace, - } - - Expect(input.Namespace).To(Equal(namespace), "Test case: "+namespace) - } - }) - - It("Should handle various output formats", func() { - formats := []string{ - "yaml", "json", "wide", "name", - "custom-columns=NAME:.metadata.name", - "jsonpath={.items[*].metadata.name}", - } - - for _, format := range formats { - input := mcptools.BackplaneClusterResourceArgs{ - Action: "get", - OutputFormat: format, - } - - Expect(input.OutputFormat).To(Equal(format), "Test case: "+format) - } - }) - - It("Should handle complex label selectors", func() { - labelSelectors := []string{ - "app=myapp", - "app=myapp,version=v1.0", - "environment!=development", - "tier in (frontend,backend)", - "!beta", - "app=myapp,version=v1.0,tier=frontend", - } - - for _, selector := range labelSelectors { - input := mcptools.BackplaneClusterResourceArgs{ - Action: "get", - ResourceType: "pod", - LabelSelector: selector, - } - - Expect(input.LabelSelector).To(Equal(selector), "Test case: "+selector) - } - }) - - It("Should handle complex field selectors", func() { - fieldSelectors := []string{ - "status.phase=Running", - "metadata.namespace!=kube-system", - "spec.nodeName=worker-1", - "status.containerStatuses[*].ready=true", - } - - for _, selector := range fieldSelectors { - input := mcptools.BackplaneClusterResourceArgs{ - Action: "get", - ResourceType: "pod", - FieldSelector: selector, - } - - Expect(input.FieldSelector).To(Equal(selector), "Test case: "+selector) - } - }) - }) - - Context("Logging-specific parameters", func() { - It("Should handle logging parameters for logs action", func() { - input := mcptools.BackplaneClusterResourceArgs{ - Action: "logs", - ResourceType: "pod", - ResourceName: "test-pod", - Follow: true, - Previous: false, - Tail: 100, - Container: "sidecar", - } - - Expect(input.Action).To(Equal("logs")) - Expect(input.Follow).To(BeTrue()) - Expect(input.Previous).To(BeFalse()) - Expect(input.Tail).To(Equal(100)) - Expect(input.Container).To(Equal("sidecar")) - }) - - It("Should handle various tail values", func() { - tailValues := []int{0, 1, 10, 50, 100, 1000} - - for _, tail := range tailValues { - input := mcptools.BackplaneClusterResourceArgs{ - Action: "logs", - Tail: tail, - } - - Expect(input.Tail).To(Equal(tail), "Test case: tail=%d", tail) - } - }) - - It("Should handle boolean flag combinations", func() { - testCases := []struct { - follow bool - previous bool - allNamespaces bool - showLabels bool - watch bool - }{ - {false, false, false, false, false}, // All false - {true, false, false, false, false}, // Only follow - {false, true, false, false, false}, // Only previous - {false, false, true, false, false}, // Only allNamespaces - {false, false, false, true, false}, // Only showLabels - {false, false, false, false, true}, // Only watch - {true, true, true, true, true}, // All true - } - - for i, tc := range testCases { - input := mcptools.BackplaneClusterResourceArgs{ - Action: "get", - Follow: tc.follow, - Previous: tc.previous, - AllNamespaces: tc.allNamespaces, - ShowLabels: tc.showLabels, - Watch: tc.watch, - } - - Expect(input.Follow).To(Equal(tc.follow), "Test case %d: follow", i) - Expect(input.Previous).To(Equal(tc.previous), "Test case %d: previous", i) - Expect(input.AllNamespaces).To(Equal(tc.allNamespaces), "Test case %d: allNamespaces", i) - Expect(input.ShowLabels).To(Equal(tc.showLabels), "Test case %d: showLabels", i) - Expect(input.Watch).To(Equal(tc.watch), "Test case %d: watch", i) - } - }) - }) - - Context("Context handling", func() { - It("Should handle context cancellation in input validation", func() { - // Create a cancelled context - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - // Test input validation with cancelled context - input := mcptools.BackplaneClusterResourceArgs{Action: ""} - - // Input validation should still work with cancelled context - result, _, err := mcptools.BackplaneClusterResource(ctx, &mcp.CallToolRequest{}, input) - - Expect(err).ToNot(BeNil()) // Should reject empty action - Expect(err.Error()).To(ContainSubstring("action cannot be empty")) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(Equal("Error: Action is required for cluster resource operations")) - }) - }) - - // Note: We don't test actual oc command execution in unit tests - // because it requires a valid kubernetes context and cluster access. - // The cluster resource tool integration is tested through the direct - // function call approach, ensuring proper argument construction and - // validation. Integration testing with actual oc commands should be - // done in separate integration test suites with proper cluster setup. -}) diff --git a/pkg/ai/mcp/backplane_console.go b/pkg/ai/mcp/backplane_console.go deleted file mode 100644 index 53210498..00000000 --- a/pkg/ai/mcp/backplane_console.go +++ /dev/null @@ -1,81 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/openshift/backplane-cli/cmd/ocm-backplane/console" -) - -type BackplaneConsoleArgs struct { - ClusterID string `json:"clusterId" jsonschema:"description:the cluster ID for backplane console"` -} - -func BackplaneConsole(ctx context.Context, request *mcp.CallToolRequest, input BackplaneConsoleArgs) (*mcp.CallToolResult, any, error) { - clusterID := strings.TrimSpace(input.ClusterID) - if clusterID == "" { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: "Error: Cluster ID is required for backplane console access"}, - }, - }, nil, fmt.Errorf("cluster ID cannot be empty") - } - - // Create console command and configure it - consoleCmd := console.NewConsoleCmd() - - // Set up command arguments - args := []string{clusterID} - consoleCmd.SetArgs(args) - - // Always open in browser when using MCP - err := consoleCmd.Flags().Set("browser", "true") - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("Error setting browser flag: %v", err)}, - }, - }, nil, nil - } - - // Run the console command in a background goroutine to avoid blocking - // The console command blocks indefinitely waiting for Ctrl+C - errChan := make(chan error, 1) - go func() { - errChan <- consoleCmd.RunE(consoleCmd, args) - }() - - // Wait briefly to see if there's an immediate error (e.g., login required, invalid cluster) - select { - case err := <-errChan: - // Command failed quickly - likely a configuration/validation error - errorMessage := fmt.Sprintf("Failed to start console for cluster '%s'. Error: %v", clusterID, err) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: errorMessage}, - }, - }, nil, nil - case <-time.After(5 * time.Second): - // No immediate error - console is starting up successfully - // The goroutine continues running in the background - } - - // Build success message - var successMessage strings.Builder - successMessage.WriteString(fmt.Sprintf("āœ… Console is starting for cluster '%s'\n\n", clusterID)) - successMessage.WriteString("🌐 Console will open in your default browser when ready\n\n") - successMessage.WriteString("āš ļø IMPORTANT:\n") - successMessage.WriteString("- The console is running in the background\n") - successMessage.WriteString("- To stop it, manually stop the containers:\n") - successMessage.WriteString(fmt.Sprintf(" podman stop console-%s monitoring-plugin-%s\n", clusterID, clusterID)) - successMessage.WriteString(fmt.Sprintf(" OR: docker stop console-%s monitoring-plugin-%s", clusterID, clusterID)) - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: successMessage.String()}, - }, - }, nil, nil -} diff --git a/pkg/ai/mcp/backplane_console_test.go b/pkg/ai/mcp/backplane_console_test.go deleted file mode 100644 index 65db1f9a..00000000 --- a/pkg/ai/mcp/backplane_console_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package mcp_test - -import ( - "context" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/modelcontextprotocol/go-sdk/mcp" - mcptools "github.com/openshift/backplane-cli/pkg/ai/mcp" -) - -var _ = Describe("BackplaneConsole", func() { - - Context("Input validation", func() { - It("Should reject empty cluster ID", func() { - input := mcptools.BackplaneConsoleArgs{ClusterID: ""} - - result, _, err := mcptools.BackplaneConsole(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("cluster ID cannot be empty")) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(Equal("Error: Cluster ID is required for backplane console access")) - }) - - It("Should reject whitespace-only cluster ID", func() { - input := mcptools.BackplaneConsoleArgs{ClusterID: " \t\n "} - - result, _, err := mcptools.BackplaneConsole(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("cluster ID cannot be empty")) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(Equal("Error: Cluster ID is required for backplane console access")) - }) - }) - - Context("Argument structure validation", func() { - It("Should accept valid BackplaneConsoleArgs structure", func() { - input := mcptools.BackplaneConsoleArgs{ - ClusterID: "test-cluster", - } - - // Verify struct fields are accessible - Expect(input.ClusterID).To(Equal("test-cluster")) - }) - - It("Should validate JSON schema tags are present", func() { - // Test that the struct works with JSON marshaling/unmarshaling - // This ensures MCP can generate proper schemas - input := mcptools.BackplaneConsoleArgs{ - ClusterID: "schema-test", - } - - Expect(input.ClusterID).To(Equal("schema-test")) - - // The struct should have proper JSON tags for MCP integration - // We can't easily test the tags at runtime, but this test documents the requirement - }) - }) - - Context("Edge cases", func() { - It("Should handle various cluster ID formats without execution", func() { - // Test cluster ID format validation without actually executing console command - testCases := []string{ - "simple-cluster", - "cluster-with-dashes", - "cluster_with_underscores", - "cluster.with.dots", - "cluster123numbers", - "UPPERCASE-CLUSTER", - "mixed-Case_Cluster.123", - } - - for _, clusterID := range testCases { - input := mcptools.BackplaneConsoleArgs{ClusterID: clusterID} - - // Test that cluster ID validation passes - trimmedID := strings.TrimSpace(input.ClusterID) - Expect(trimmedID).To(Equal(clusterID), "Test case: "+clusterID) - Expect(trimmedID).ToNot(BeEmpty(), "Test case: "+clusterID) - - // Test struct field access - Expect(input.ClusterID).To(Equal(clusterID), "Test case: "+clusterID) - } - }) - - It("Should handle different cluster IDs", func() { - testCases := []string{ - "cluster-1", - "cluster-2", - "cluster-3", - } - - for _, clusterID := range testCases { - input := mcptools.BackplaneConsoleArgs{ - ClusterID: clusterID, - } - - // Verify struct configuration - Expect(input.ClusterID).To(Equal(clusterID)) - } - }) - - It("Should handle context cancellation in input validation", func() { - // Create a cancelled context - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - // Test input validation with cancelled context - input := mcptools.BackplaneConsoleArgs{ClusterID: ""} - - // Input validation should still work with cancelled context - result, _, err := mcptools.BackplaneConsole(ctx, &mcp.CallToolRequest{}, input) - - Expect(err).ToNot(BeNil()) // Should reject empty cluster ID - Expect(err.Error()).To(ContainSubstring("cluster ID cannot be empty")) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(Equal("Error: Cluster ID is required for backplane console access")) - }) - }) - - Context("MCP protocol compliance", func() { - It("Should return proper MCP response format for validation errors", func() { - input := mcptools.BackplaneConsoleArgs{ClusterID: ""} // Invalid input - - result, output, err := mcptools.BackplaneConsole(context.Background(), &mcp.CallToolRequest{}, input) - - // Verify MCP response structure for validation errors - Expect(err).ToNot(BeNil()) // Input validation error - Expect(result).ToNot(BeNil()) - Expect(result.Content).To(HaveLen(1)) - - // Verify output structure (should be nil) - Expect(output).To(BeNil()) - - // Verify content type - textContent, ok := result.Content[0].(*mcp.TextContent) - Expect(ok).To(BeTrue()) - Expect(textContent.Text).ToNot(BeEmpty()) - }) - - It("Should have proper JSON schema structure for MCP integration", func() { - // Test the input argument structure for MCP compatibility - input := mcptools.BackplaneConsoleArgs{ - ClusterID: "schema-validation-test", - } - - // Verify all fields are accessible and properly typed - Expect(input.ClusterID).To(BeAssignableToTypeOf("")) - - // The struct should work with MCP's JSON schema generation - Expect(input.ClusterID).To(Equal("schema-validation-test")) - }) - }) - - // Note: We don't test actual console command execution in unit tests - // because the console command starts containers and runs a web server, - // which would cause tests to hang. The console integration is tested - // through the direct function call approach, ensuring we use - // console.NewConsoleCmd() instead of external command execution. - // Integration testing with actual console functionality should be - // done in separate integration test suites. -}) diff --git a/pkg/ai/mcp/backplane_info.go b/pkg/ai/mcp/backplane_info.go deleted file mode 100644 index ce317f9f..00000000 --- a/pkg/ai/mcp/backplane_info.go +++ /dev/null @@ -1,82 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - "os" - - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/openshift/backplane-cli/pkg/cli/config" - "github.com/openshift/backplane-cli/pkg/info" -) - -// BackplaneInfoInput represents the input for the backplane-info tool -type BackplaneInfoInput struct { - // No input parameters needed for backplane info -} - -// GetBackplaneInfo retrieves comprehensive information about the backplane CLI installation -// and configuration -func GetBackplaneInfo(ctx context.Context, req *mcp.CallToolRequest, input BackplaneInfoInput) (*mcp.CallToolResult, any, error) { - // Get version information - version := info.DefaultInfoService.GetVersion() - - // Get configuration information - bpConfig, err := config.GetBackplaneConfiguration() - var configInfo string - if err != nil { - configInfo = fmt.Sprintf("Error loading configuration: %v", err) - } else { - // Helper function logic inlined - sessionDir := bpConfig.SessionDirectory - if sessionDir == "" { - sessionDir = info.BackplaneDefaultSessionDirectory - } - - proxyURL := "not configured" - if bpConfig.ProxyURL != nil && *bpConfig.ProxyURL != "" { - proxyURL = *bpConfig.ProxyURL - } - - awsProxy := "not configured" - if bpConfig.AwsProxy != nil && *bpConfig.AwsProxy != "" { - awsProxy = *bpConfig.AwsProxy - } - - configInfo = fmt.Sprintf(`Configuration: -- Backplane URL: %s -- Session Directory: %s -- Proxy URL: %s -- AWS Proxy: %s -- Display Cluster Info: %t -- GovCloud: %t`, - bpConfig.URL, - sessionDir, - proxyURL, - awsProxy, - bpConfig.DisplayClusterInfo, - bpConfig.Govcloud) - } - - // Get current working directory and environment info - cwd, _ := os.Getwd() - homeDir, _ := os.UserHomeDir() - - // Build complete info response - infoText := fmt.Sprintf(`Backplane CLI Information: - -Version: %s - -%s - -Environment: -- Home Directory: %s -- Current Directory: %s -- Shell: %s`, version, configInfo, homeDir, cwd, os.Getenv("SHELL")) - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: infoText}, - }, - }, nil, nil -} diff --git a/pkg/ai/mcp/backplane_info_test.go b/pkg/ai/mcp/backplane_info_test.go deleted file mode 100644 index 356b3feb..00000000 --- a/pkg/ai/mcp/backplane_info_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package mcp_test - -import ( - "context" - "os" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/spf13/viper" - "go.uber.org/mock/gomock" - - "github.com/modelcontextprotocol/go-sdk/mcp" - mcptools "github.com/openshift/backplane-cli/pkg/ai/mcp" - "github.com/openshift/backplane-cli/pkg/info" - infoMock "github.com/openshift/backplane-cli/pkg/info/mocks" -) - -var _ = Describe("BackplaneInfo", func() { - var ( - mockCtrl *gomock.Controller - mockInfoService *infoMock.MockInfoService - originalInfoService info.InfoService - ) - - BeforeEach(func() { - mockCtrl = gomock.NewController(GinkgoT()) - mockInfoService = infoMock.NewMockInfoService(mockCtrl) - - // Store original service to restore later - originalInfoService = info.DefaultInfoService - info.DefaultInfoService = mockInfoService - - // Clear all environment variables that might affect configuration - _ = os.Unsetenv("BACKPLANE_URL") - _ = os.Unsetenv("HTTPS_PROXY") - _ = os.Unsetenv("BACKPLANE_AWS_PROXY") - _ = os.Unsetenv("BACKPLANE_CONFIG") - - // Clear viper settings to ensure clean state - viper.Reset() - }) - - AfterEach(func() { - mockCtrl.Finish() - - // Restore original service - info.DefaultInfoService = originalInfoService - - // Clean up environment variables - _ = os.Unsetenv("BACKPLANE_URL") - _ = os.Unsetenv("HTTPS_PROXY") - _ = os.Unsetenv("BACKPLANE_AWS_PROXY") - _ = os.Unsetenv("BACKPLANE_CONFIG") - _ = os.Unsetenv("SHELL") - - // Clear viper settings - viper.Reset() - }) - - Context("When getting backplane info", func() { - It("Should return comprehensive info with all configuration details", func() { - // Mock version service - mockInfoService.EXPECT().GetVersion().Return("1.2.3").Times(1) - - // Set up environment for configuration - _ = os.Setenv("BACKPLANE_URL", "https://api.backplane.example.com") - _ = os.Setenv("HTTPS_PROXY", "https://proxy.example.com:8080") - _ = os.Setenv("BACKPLANE_AWS_PROXY", "https://aws-proxy.example.com:8080") - - // Set up viper configuration - viper.Set("session-dir", "custom-session") - viper.Set("display-cluster-info", true) - viper.Set("govcloud", false) - - // Execute the function - result, _, err := mcptools.GetBackplaneInfo(context.Background(), &mcp.CallToolRequest{}, mcptools.BackplaneInfoInput{}) - - // Verify results - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - Expect(result.Content).To(HaveLen(1)) - - textContent, ok := result.Content[0].(*mcp.TextContent) - Expect(ok).To(BeTrue()) - - infoText := textContent.Text - Expect(infoText).To(ContainSubstring("Version: 1.2.3")) - Expect(infoText).To(ContainSubstring("Backplane URL: https://api.backplane.example.com")) - Expect(infoText).To(ContainSubstring("Session Directory: custom-session")) - Expect(infoText).To(ContainSubstring("Proxy URL: https://proxy.example.com:8080")) - Expect(infoText).To(ContainSubstring("AWS Proxy: https://aws-proxy.example.com:8080")) - Expect(infoText).To(ContainSubstring("Display Cluster Info: true")) - Expect(infoText).To(ContainSubstring("GovCloud: false")) - Expect(infoText).To(ContainSubstring("Environment:")) - }) - - It("Should handle missing proxy configuration gracefully", func() { - // Mock version service - mockInfoService.EXPECT().GetVersion().Return("2.0.0").Times(1) - - // Set up minimal environment for configuration - _ = os.Setenv("BACKPLANE_URL", "https://api.backplane.example.com") - // Explicitly set empty proxy to override system defaults - _ = os.Setenv("HTTPS_PROXY", "") - - // Set up viper configuration - viper.Set("govcloud", false) - - // Execute the function - result, _, err := mcptools.GetBackplaneInfo(context.Background(), &mcp.CallToolRequest{}, mcptools.BackplaneInfoInput{}) - - // Verify results - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - infoText := textContent.Text - - Expect(infoText).To(ContainSubstring("Version: 2.0.0")) - Expect(infoText).To(ContainSubstring("Backplane URL: https://api.backplane.example.com")) - Expect(infoText).To(ContainSubstring("Session Directory: backplane")) // default value - // Don't check specific proxy values as they may be system-dependent - }) - - It("Should handle unknown version gracefully", func() { - // Mock version service to return unknown - mockInfoService.EXPECT().GetVersion().Return("unknown").Times(1) - - // Set up minimal environment - _ = os.Setenv("BACKPLANE_URL", "https://api.backplane.example.com") - viper.Set("govcloud", false) - - // Execute the function - result, _, err := mcptools.GetBackplaneInfo(context.Background(), &mcp.CallToolRequest{}, mcptools.BackplaneInfoInput{}) - - // Verify results - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - infoText := textContent.Text - - Expect(infoText).To(ContainSubstring("Version: unknown")) - }) - - It("Should include environment information", func() { - // Mock version service - mockInfoService.EXPECT().GetVersion().Return("3.0.0").Times(1) - - // Set up minimal environment - _ = os.Setenv("BACKPLANE_URL", "https://api.backplane.example.com") - _ = os.Setenv("SHELL", "/bin/zsh") - viper.Set("govcloud", false) - - // Execute the function - result, _, err := mcptools.GetBackplaneInfo(context.Background(), &mcp.CallToolRequest{}, mcptools.BackplaneInfoInput{}) - - // Verify results - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - infoText := textContent.Text - - Expect(infoText).To(ContainSubstring("Environment:")) - Expect(infoText).To(ContainSubstring("Home Directory:")) - Expect(infoText).To(ContainSubstring("Current Directory:")) - Expect(infoText).To(ContainSubstring("Shell: /bin/zsh")) - }) - - It("Should handle empty session directory configuration", func() { - // Mock version service - mockInfoService.EXPECT().GetVersion().Return("1.1.0").Times(1) - - // Set up environment - _ = os.Setenv("BACKPLANE_URL", "https://api.backplane.example.com") - viper.Set("govcloud", false) - // Don't set session-dir, should use default - - // Execute the function - result, _, err := mcptools.GetBackplaneInfo(context.Background(), &mcp.CallToolRequest{}, mcptools.BackplaneInfoInput{}) - - // Verify results - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - infoText := textContent.Text - - Expect(infoText).To(ContainSubstring("Session Directory: backplane")) // default from info.BackplaneDefaultSessionDirectory - }) - }) - - Context("Input validation", func() { - It("Should accept empty input struct", func() { - // Mock version service - mockInfoService.EXPECT().GetVersion().Return("1.0.0").Times(1) - - // Set up minimal environment - _ = os.Setenv("BACKPLANE_URL", "https://api.backplane.example.com") - viper.Set("govcloud", false) - - // Execute with empty input - input := mcptools.BackplaneInfoInput{} - result, _, err := mcptools.GetBackplaneInfo(context.Background(), &mcp.CallToolRequest{}, input) - - // Should work fine - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - }) - }) -}) diff --git a/pkg/ai/mcp/backplane_login.go b/pkg/ai/mcp/backplane_login.go deleted file mode 100644 index 36b17ea5..00000000 --- a/pkg/ai/mcp/backplane_login.go +++ /dev/null @@ -1,47 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - "strings" - - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/openshift/backplane-cli/cmd/ocm-backplane/login" -) - -type BackplaneLoginArgs struct { - ClusterID string `json:"clusterId" jsonschema:"description:the cluster ID for backplane login"` -} - -func BackplaneLogin(ctx context.Context, request *mcp.CallToolRequest, input BackplaneLoginArgs) (*mcp.CallToolResult, any, error) { - clusterID := strings.TrimSpace(input.ClusterID) - if clusterID == "" { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: "Error: Cluster ID is required for backplane login"}, - }, - }, nil, fmt.Errorf("cluster ID cannot be empty") - } - - // Call the runLogin function directly instead of using exec - err := login.LoginCmd.RunE(login.LoginCmd, []string{clusterID}) - - if err != nil { - errorMessage := fmt.Sprintf("Failed to login to cluster '%s'. Error: %v", clusterID, err) - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: errorMessage}, - }, - }, nil, nil // Return nil error since we're handling it gracefully - } - - // Success case - successMessage := fmt.Sprintf("Successfully logged in to cluster '%s'", clusterID) - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: successMessage}, - }, - }, nil, nil -} diff --git a/pkg/ai/mcp/backplane_login_test.go b/pkg/ai/mcp/backplane_login_test.go deleted file mode 100644 index 2d3f13a8..00000000 --- a/pkg/ai/mcp/backplane_login_test.go +++ /dev/null @@ -1,252 +0,0 @@ -package mcp_test - -import ( - "context" - "errors" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/spf13/cobra" - - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/openshift/backplane-cli/cmd/ocm-backplane/login" - mcptools "github.com/openshift/backplane-cli/pkg/ai/mcp" -) - -var _ = Describe("BackplaneLogin", func() { - var ( - originalRunE func(cmd *cobra.Command, args []string) error - ) - - BeforeEach(func() { - // Store original RunE function to restore later - originalRunE = login.LoginCmd.RunE - }) - - AfterEach(func() { - // Restore original RunE function - login.LoginCmd.RunE = originalRunE - }) - - Context("Input validation", func() { - It("Should reject empty cluster ID", func() { - input := mcptools.BackplaneLoginArgs{ClusterID: ""} - - result, _, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("cluster ID cannot be empty")) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(Equal("Error: Cluster ID is required for backplane login")) - }) - - It("Should reject whitespace-only cluster ID", func() { - input := mcptools.BackplaneLoginArgs{ClusterID: " \t\n "} - - result, _, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("cluster ID cannot be empty")) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(Equal("Error: Cluster ID is required for backplane login")) - }) - - It("Should trim whitespace from valid cluster ID", func() { - // Mock successful login - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - // Verify the trimmed cluster ID is passed - Expect(args).To(HaveLen(1)) - Expect(args[0]).To(Equal("cluster-123")) - return nil - } - - input := mcptools.BackplaneLoginArgs{ClusterID: " cluster-123 "} - - result, _, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(Equal("Successfully logged in to cluster 'cluster-123'")) - }) - }) - - Context("Successful login", func() { - It("Should return success message for valid cluster ID", func() { - // Mock successful login - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - Expect(args).To(HaveLen(1)) - Expect(args[0]).To(Equal("test-cluster-456")) - return nil - } - - input := mcptools.BackplaneLoginArgs{ClusterID: "test-cluster-456"} - - result, _, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - Expect(result.Content).To(HaveLen(1)) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(Equal("Successfully logged in to cluster 'test-cluster-456'")) - }) - - It("Should handle cluster IDs with special characters", func() { - specialClusterID := "cluster-with-dashes_and_underscores.123" - - // Mock successful login - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - Expect(args).To(HaveLen(1)) - Expect(args[0]).To(Equal(specialClusterID)) - return nil - } - - input := mcptools.BackplaneLoginArgs{ClusterID: specialClusterID} - - result, _, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - expectedMessage := "Successfully logged in to cluster 'cluster-with-dashes_and_underscores.123'" - Expect(textContent.Text).To(Equal(expectedMessage)) - }) - }) - - Context("Failed login", func() { - It("Should handle login command errors gracefully", func() { - // Mock failed login - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - return errors.New("cluster not found") - } - - input := mcptools.BackplaneLoginArgs{ClusterID: "non-existent-cluster"} - - result, _, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, input) - - // Should not return error in the function signature (graceful handling) - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - Expect(result.Content).To(HaveLen(1)) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(ContainSubstring("Failed to login to cluster 'non-existent-cluster'")) - Expect(textContent.Text).To(ContainSubstring("cluster not found")) - }) - - It("Should handle authentication errors", func() { - // Mock authentication failure - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - return errors.New("authentication failed: invalid token") - } - - input := mcptools.BackplaneLoginArgs{ClusterID: "auth-test-cluster"} - - result, _, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil()) // Graceful error handling - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(ContainSubstring("Failed to login to cluster 'auth-test-cluster'")) - Expect(textContent.Text).To(ContainSubstring("authentication failed: invalid token")) - }) - - It("Should handle network connectivity errors", func() { - // Mock network error - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - return errors.New("network error: connection timeout") - } - - input := mcptools.BackplaneLoginArgs{ClusterID: "network-test-cluster"} - - result, _, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil()) // Graceful error handling - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(ContainSubstring("Failed to login to cluster 'network-test-cluster'")) - Expect(textContent.Text).To(ContainSubstring("network error: connection timeout")) - }) - }) - - Context("Response format validation", func() { - It("Should return valid MCP response structure for success", func() { - // Mock successful login - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - return nil - } - - input := mcptools.BackplaneLoginArgs{ClusterID: "format-test-cluster"} - - result, output, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, input) - - // Verify response structure - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - Expect(result.Content).To(HaveLen(1)) - - // Verify output structure (should be nil) - Expect(output).To(BeNil()) - - // Verify content type - textContent, ok := result.Content[0].(*mcp.TextContent) - Expect(ok).To(BeTrue()) - Expect(textContent.Text).ToNot(BeEmpty()) - }) - - It("Should return valid MCP response structure for error", func() { - // Mock failed login - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - return errors.New("test error") - } - - input := mcptools.BackplaneLoginArgs{ClusterID: "error-format-test"} - - result, output, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, input) - - // Verify response structure - Expect(err).To(BeNil()) // Graceful error handling - Expect(result).ToNot(BeNil()) - Expect(result.Content).To(HaveLen(1)) - - // Verify output structure (should be nil) - Expect(output).To(BeNil()) - - // Verify content type - textContent, ok := result.Content[0].(*mcp.TextContent) - Expect(ok).To(BeTrue()) - Expect(textContent.Text).ToNot(BeEmpty()) - Expect(textContent.Text).To(ContainSubstring("Failed to login")) - }) - }) - - Context("Context handling", func() { - It("Should respect context cancellation", func() { - // Create a cancelled context - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - // Mock login that would normally succeed - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - return nil - } - - input := mcptools.BackplaneLoginArgs{ClusterID: "context-test-cluster"} - - // Function should still complete since context isn't directly used in current implementation - result, _, err := mcptools.BackplaneLogin(ctx, &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - }) - }) -}) diff --git a/pkg/ai/mcp/mcp_suite_test.go b/pkg/ai/mcp/mcp_suite_test.go deleted file mode 100644 index 3c420c47..00000000 --- a/pkg/ai/mcp/mcp_suite_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package mcp_test - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestMCP(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "MCP Suite") -} diff --git a/pkg/ai/mcp/mcp_tool_integration_test.go b/pkg/ai/mcp/mcp_tool_integration_test.go deleted file mode 100644 index 7ef43be2..00000000 --- a/pkg/ai/mcp/mcp_tool_integration_test.go +++ /dev/null @@ -1,468 +0,0 @@ -package mcp_test - -import ( - "context" - "fmt" - "os" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "go.uber.org/mock/gomock" - - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/openshift/backplane-cli/cmd/ocm-backplane/login" - mcptools "github.com/openshift/backplane-cli/pkg/ai/mcp" - "github.com/openshift/backplane-cli/pkg/info" - infoMock "github.com/openshift/backplane-cli/pkg/info/mocks" -) - -var _ = Describe("MCP Tool Integration", func() { - var ( - // Login tool mocks - originalLoginRunE func(cmd *cobra.Command, args []string) error - - // Info tool mocks - mockCtrl *gomock.Controller - mockInfoService *infoMock.MockInfoService - originalInfoService info.InfoService - ) - - BeforeEach(func() { - // Setup login tool mocking - originalLoginRunE = login.LoginCmd.RunE - - // Setup info tool mocking - mockCtrl = gomock.NewController(GinkgoT()) - mockInfoService = infoMock.NewMockInfoService(mockCtrl) - originalInfoService = info.DefaultInfoService - info.DefaultInfoService = mockInfoService - - // Clear environment and viper for clean tests - _ = os.Unsetenv("BACKPLANE_URL") - _ = os.Unsetenv("HTTPS_PROXY") - _ = os.Unsetenv("BACKPLANE_AWS_PROXY") - _ = os.Unsetenv("BACKPLANE_CONFIG") - viper.Reset() - }) - - AfterEach(func() { - // Restore login tool - login.LoginCmd.RunE = originalLoginRunE - - // Restore info tool - mockCtrl.Finish() - info.DefaultInfoService = originalInfoService - - // Clean up environment - _ = os.Unsetenv("BACKPLANE_URL") - _ = os.Unsetenv("HTTPS_PROXY") - _ = os.Unsetenv("BACKPLANE_AWS_PROXY") - _ = os.Unsetenv("BACKPLANE_CONFIG") - _ = os.Unsetenv("SHELL") - viper.Reset() - }) - - Context("MCP Login Tool Integration", func() { - It("Should integrate login tool with MCP server correctly", func() { - // Mock successful login for integration test - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - Expect(args).To(HaveLen(1)) - Expect(args[0]).To(Equal("integration-test-cluster")) - return nil - } - - // Test the MCP tool directly as it would be called by an MCP client - input := mcptools.BackplaneLoginArgs{ClusterID: "integration-test-cluster"} - result, output, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, input) - - // Verify MCP integration works correctly - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - Expect(output).To(BeNil()) - - // Verify response follows MCP protocol - Expect(result.Content).To(HaveLen(1)) - textContent, ok := result.Content[0].(*mcp.TextContent) - Expect(ok).To(BeTrue()) - Expect(textContent.Text).To(Equal("Successfully logged in to cluster 'integration-test-cluster'")) - }) - - It("Should handle MCP tool name format correctly", func() { - // When used through Gemini or Claude, the tool would be called as: - // "backplane__login" (server name + __ + tool name) - // This test verifies our tool works correctly for that use case - - // Mock successful login - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - return nil - } - - // Test with cluster ID that might come from MCP client - input := mcptools.BackplaneLoginArgs{ClusterID: "mcp-client-cluster-456"} - result, _, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(ContainSubstring("Successfully logged in to cluster 'mcp-client-cluster-456'")) - }) - - It("Should handle realistic cluster ID formats from MCP clients", func() { - testCases := []struct { - clusterID string - expected string - }{ - {"abc123", "Successfully logged in to cluster 'abc123'"}, - {"cluster-prod-us-east-1", "Successfully logged in to cluster 'cluster-prod-us-east-1'"}, - {"dev_cluster_001", "Successfully logged in to cluster 'dev_cluster_001'"}, - {"staging.cluster.example.com", "Successfully logged in to cluster 'staging.cluster.example.com'"}, - } - - for _, tc := range testCases { - // Mock successful login for each test case - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - Expect(args[0]).To(Equal(tc.clusterID)) - return nil - } - - input := mcptools.BackplaneLoginArgs{ClusterID: tc.clusterID} - result, _, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil(), "Test case: "+tc.clusterID) - Expect(result).ToNot(BeNil(), "Test case: "+tc.clusterID) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(Equal(tc.expected), "Test case: "+tc.clusterID) - } - }) - - It("Should provide meaningful error messages for MCP clients", func() { - // Mock a realistic login failure - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - return fmt.Errorf("cluster '%s' not found in your accessible clusters", args[0]) - } - - input := mcptools.BackplaneLoginArgs{ClusterID: "nonexistent-cluster"} - result, _, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, input) - - // Should handle error gracefully for MCP clients - Expect(err).To(BeNil()) // No exception thrown - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(ContainSubstring("Failed to login to cluster 'nonexistent-cluster'")) - Expect(textContent.Text).To(ContainSubstring("not found in your accessible clusters")) - }) - }) - - Context("MCP Info Tool Integration", func() { - It("Should integrate info tool with MCP server correctly", func() { - // Mock version service - mockInfoService.EXPECT().GetVersion().Return("1.5.0").Times(1) - - // Set up environment for configuration - _ = os.Setenv("BACKPLANE_URL", "https://api.backplane.example.com") - _ = os.Setenv("HTTPS_PROXY", "https://proxy.example.com:8080") - viper.Set("govcloud", false) - viper.Set("display-cluster-info", true) - - // Test the MCP tool directly as it would be called by an MCP client - input := mcptools.BackplaneInfoInput{} - result, output, err := mcptools.GetBackplaneInfo(context.Background(), &mcp.CallToolRequest{}, input) - - // Verify MCP integration works correctly - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - Expect(output).To(BeNil()) // Info tool returns nil for output - - // Verify response follows MCP protocol - Expect(result.Content).To(HaveLen(1)) - textContent, ok := result.Content[0].(*mcp.TextContent) - Expect(ok).To(BeTrue()) - - infoText := textContent.Text - Expect(infoText).To(ContainSubstring("Version: 1.5.0")) - Expect(infoText).To(ContainSubstring("Backplane URL: https://api.backplane.example.com")) - Expect(infoText).To(ContainSubstring("Environment:")) - }) - - It("Should handle MCP info tool name format correctly", func() { - // When used through Gemini or Claude, the tool would be called as: - // "backplane__info" (server name + __ + tool name) - - // Mock version service - mockInfoService.EXPECT().GetVersion().Return("2.0.0").Times(1) - - // Set up minimal environment - _ = os.Setenv("BACKPLANE_URL", "https://test.backplane.example.com") - viper.Set("govcloud", false) - - input := mcptools.BackplaneInfoInput{} - result, _, err := mcptools.GetBackplaneInfo(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(ContainSubstring("Version: 2.0.0")) - Expect(textContent.Text).To(ContainSubstring("Backplane URL: https://test.backplane.example.com")) - }) - - It("Should verify BackplaneInfoInput JSON schema compatibility", func() { - // Test that the input struct works with MCP's JSON schema generation - input := mcptools.BackplaneInfoInput{} - - // Mock version service for actual call - mockInfoService.EXPECT().GetVersion().Return("schema-test").Times(1) - _ = os.Setenv("BACKPLANE_URL", "https://api.backplane.example.com") - viper.Set("govcloud", false) - - // The BackplaneInfoInput struct should work with MCP even though it's empty - result, _, err := mcptools.GetBackplaneInfo(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(ContainSubstring("Version: schema-test")) - }) - - It("Should handle configuration errors gracefully for MCP clients", func() { - // Mock version service - mockInfoService.EXPECT().GetVersion().Return("1.0.0").Times(1) - - // Don't set required configuration to trigger error - viper.Set("govcloud", false) - // No BACKPLANE_URL or proxy configured - - input := mcptools.BackplaneInfoInput{} - result, _, err := mcptools.GetBackplaneInfo(context.Background(), &mcp.CallToolRequest{}, input) - - // Should handle configuration errors gracefully - Expect(err).To(BeNil()) // No exception thrown - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(ContainSubstring("Version: 1.0.0")) - // Should still provide version even if config fails - }) - - It("Should provide comprehensive information for AI assistants", func() { - // Mock version service - mockInfoService.EXPECT().GetVersion().Return("3.1.0").Times(1) - - // Set up comprehensive configuration - _ = os.Setenv("BACKPLANE_URL", "https://prod.backplane.example.com") - _ = os.Setenv("HTTPS_PROXY", "https://corporate-proxy.example.com:8080") - _ = os.Setenv("SHELL", "/bin/zsh") - viper.Set("session-dir", "custom-backplane-sessions") - viper.Set("display-cluster-info", true) - viper.Set("govcloud", false) - - input := mcptools.BackplaneInfoInput{} - result, _, err := mcptools.GetBackplaneInfo(context.Background(), &mcp.CallToolRequest{}, input) - - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - infoText := textContent.Text - - // Verify comprehensive information is provided - Expect(infoText).To(ContainSubstring("Version: 3.1.0")) - Expect(infoText).To(ContainSubstring("Backplane URL: https://prod.backplane.example.com")) - Expect(infoText).To(ContainSubstring("Session Directory: custom-backplane-sessions")) - Expect(infoText).To(ContainSubstring("Proxy URL: https://corporate-proxy.example.com:8080")) - Expect(infoText).To(ContainSubstring("Display Cluster Info: true")) - Expect(infoText).To(ContainSubstring("GovCloud: false")) - Expect(infoText).To(ContainSubstring("Shell: /bin/zsh")) - Expect(infoText).To(ContainSubstring("Environment:")) - }) - }) - - Context("Performance and Reliability", func() { - It("Should complete login operations quickly for MCP responsiveness", func() { - // MCP clients expect reasonably fast responses - callCount := 0 - - // Mock fast login - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - callCount++ - return nil - } - - input := mcptools.BackplaneLoginArgs{ClusterID: "perf-test-cluster"} - - // Multiple calls should all succeed - for i := 0; i < 5; i++ { - result, _, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, input) - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - } - - Expect(callCount).To(Equal(5)) - }) - - It("Should complete info operations quickly for MCP responsiveness", func() { - // Mock version service for multiple calls - mockInfoService.EXPECT().GetVersion().Return("perf-test").Times(3) - - // Set up minimal environment - _ = os.Setenv("BACKPLANE_URL", "https://api.backplane.example.com") - viper.Set("govcloud", false) - - input := mcptools.BackplaneInfoInput{} - - // Multiple calls should all succeed quickly - for i := 0; i < 3; i++ { - result, _, err := mcptools.GetBackplaneInfo(context.Background(), &mcp.CallToolRequest{}, input) - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(ContainSubstring("Version: perf-test")) - } - }) - - It("Should maintain consistent behavior across multiple tool calls", func() { - // Test both tools in sequence to ensure no interference - - // Mock login - loginCalls := 0 - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - loginCalls++ - return nil - } - - // Mock info service - mockInfoService.EXPECT().GetVersion().Return("consistency-test").Times(2) - _ = os.Setenv("BACKPLANE_URL", "https://api.backplane.example.com") - viper.Set("govcloud", false) - - // Alternate between login and info calls - loginInput := mcptools.BackplaneLoginArgs{ClusterID: "consistency-cluster"} - infoInput := mcptools.BackplaneInfoInput{} - - for i := 0; i < 2; i++ { - // Login call - loginResult, _, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, loginInput) - Expect(err).To(BeNil()) - Expect(loginResult).ToNot(BeNil()) - - // Info call - infoResult, _, err := mcptools.GetBackplaneInfo(context.Background(), &mcp.CallToolRequest{}, infoInput) - Expect(err).To(BeNil()) - Expect(infoResult).ToNot(BeNil()) - } - - Expect(loginCalls).To(Equal(2)) - }) - }) - - Context("MCP Cluster Resource Tool Integration", func() { - It("Should validate cluster resource tool input correctly", func() { - // Test valid action - validInput := mcptools.BackplaneClusterResourceArgs{ - Action: "get", - ResourceType: "pods", - Namespace: "default", - } - - result, _, err := mcptools.BackplaneClusterResource(context.Background(), &mcp.CallToolRequest{}, validInput) - - // Should pass validation (may fail on oc execution in test environment) - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).ToNot(ContainSubstring("is not allowed")) - }) - - It("Should reject invalid actions for cluster resource tool", func() { - // Test invalid action - invalidInput := mcptools.BackplaneClusterResourceArgs{ - Action: "delete", // Not allowed - } - - result, _, err := mcptools.BackplaneClusterResource(context.Background(), &mcp.CallToolRequest{}, invalidInput) - - // Should fail validation - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("unsupported action")) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).To(ContainSubstring("is not allowed")) - Expect(textContent.Text).To(ContainSubstring("Only read actions are supported")) - }) - - It("Should handle complex cluster resource parameters", func() { - // Test with comprehensive parameters - complexInput := mcptools.BackplaneClusterResourceArgs{ - Action: "get", - ResourceType: "pods", - Namespace: "kube-system", - LabelSelector: "app=important", - OutputFormat: "yaml", - ShowLabels: true, - } - - result, _, err := mcptools.BackplaneClusterResource(context.Background(), &mcp.CallToolRequest{}, complexInput) - - // Should pass validation - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil()) - - textContent := result.Content[0].(*mcp.TextContent) - Expect(textContent.Text).ToNot(ContainSubstring("is not allowed")) - }) - }) - - Context("MCP Protocol Compliance", func() { - It("Should return proper MCP response format for all tools", func() { - // Test login tool - login.LoginCmd.RunE = func(cmd *cobra.Command, args []string) error { - return nil - } - - loginInput := mcptools.BackplaneLoginArgs{ClusterID: "protocol-test"} - loginResult, loginOutput, err := mcptools.BackplaneLogin(context.Background(), &mcp.CallToolRequest{}, loginInput) - - Expect(err).To(BeNil()) - Expect(loginResult).ToNot(BeNil()) - Expect(loginOutput).To(BeNil()) // Login returns nil - Expect(loginResult.Content).To(HaveLen(1)) - _, ok := loginResult.Content[0].(*mcp.TextContent) - Expect(ok).To(BeTrue()) - - // Test info tool - mockInfoService.EXPECT().GetVersion().Return("protocol-test").Times(1) - _ = os.Setenv("BACKPLANE_URL", "https://api.backplane.example.com") - viper.Set("govcloud", false) - - infoInput := mcptools.BackplaneInfoInput{} - infoResult, infoOutput, err := mcptools.GetBackplaneInfo(context.Background(), &mcp.CallToolRequest{}, infoInput) - - Expect(err).To(BeNil()) - Expect(infoResult).ToNot(BeNil()) - Expect(infoOutput).To(BeNil()) // Info returns nil - Expect(infoResult.Content).To(HaveLen(1)) - _, ok = infoResult.Content[0].(*mcp.TextContent) - Expect(ok).To(BeTrue()) - - // Test cluster resource tool (validation only) - resourceInput := mcptools.BackplaneClusterResourceArgs{Action: "get", ResourceType: "pods"} - resourceResult, resourceOutput, err := mcptools.BackplaneClusterResource(context.Background(), &mcp.CallToolRequest{}, resourceInput) - - Expect(err).To(BeNil()) // Should pass validation - Expect(resourceResult).ToNot(BeNil()) - Expect(resourceOutput).To(BeNil()) // Returns nil - Expect(resourceResult.Content).To(HaveLen(1)) - _, ok = resourceResult.Content[0].(*mcp.TextContent) - Expect(ok).To(BeTrue()) - }) - }) -})