Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,39 @@ jobs:
- name: Run E2E smoke test
run: make e2e-smoke-test

- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '11'

- name: Setup proto files
run: ./scripts/setup-proto-files.sh

- name: Generate proto descriptors
run: make proto-generate

- name: Download WireMock
run: make mock-download

- name: Start WireMock
run: make mock-start

- name: Run integration tests
run: make test-integration-coverage

- name: Stop WireMock
if: always()
run: make mock-stop

- name: Upload WireMock logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: wiremock-logs
path: wiremock/wiremock.log
if-no-files-found: ignore

- name: Upload test results to Codecov
uses: codecov/test-results-action@v1
with:
Expand All @@ -56,3 +89,14 @@ jobs:
files: ./coverage.out
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
flags: unit
name: unit-tests

- name: Upload integration test coverage to Codecov
uses: codecov/codecov-action@v5
with:
file: ./coverage-integration.out
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
flags: integration
name: integration-tests
51 changes: 3 additions & 48 deletions cmd/stackrox-mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,12 @@ package main
import (
"context"
"flag"
"log/slog"
"os"
"os/signal"
"syscall"

"github.com/stackrox/stackrox-mcp/internal/client"
"github.com/stackrox/stackrox-mcp/internal/app"
"github.com/stackrox/stackrox-mcp/internal/config"
"github.com/stackrox/stackrox-mcp/internal/logging"
"github.com/stackrox/stackrox-mcp/internal/server"
"github.com/stackrox/stackrox-mcp/internal/toolsets"
toolsetConfig "github.com/stackrox/stackrox-mcp/internal/toolsets/config"
toolsetVulnerability "github.com/stackrox/stackrox-mcp/internal/toolsets/vulnerability"
)

// getToolsets initializes and returns all available toolsets.
func getToolsets(cfg *config.Config, c *client.Client) []toolsets.Toolset {
return []toolsets.Toolset{
toolsetConfig.NewToolset(cfg, c),
toolsetVulnerability.NewToolset(cfg, c),
}
}

func main() {
logging.SetupLogging()

Expand All @@ -38,38 +22,9 @@ func main() {
logging.Fatal("Failed to load configuration", err)
}

// Log full configuration with sensitive data redacted.
slog.Info("Configuration loaded successfully", "config", cfg.Redacted())

stackroxClient, err := client.NewClient(&cfg.Central)
if err != nil {
logging.Fatal("Failed to create StackRox client", err)
}

registry := toolsets.NewRegistry(cfg, getToolsets(cfg, stackroxClient))
srv := server.NewServer(cfg, registry)

// Set up context with signal handling for graceful shutdown.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

err = stackroxClient.Connect(ctx)
if err != nil {
logging.Fatal("Failed to connect to StackRox server", err)
}

sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

go func() {
<-sigChan
slog.Info("Received shutdown signal")
cancel()
}()

slog.Info("Starting StackRox MCP server")
ctx := context.Background()

if err := srv.Start(ctx); err != nil {
if err := app.Run(ctx, cfg, nil, nil); err != nil {
logging.Fatal("Server error", err)
}
}
12 changes: 11 additions & 1 deletion cmd/stackrox-mcp/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,20 @@ import (
"github.com/stackrox/stackrox-mcp/internal/server"
"github.com/stackrox/stackrox-mcp/internal/testutil"
"github.com/stackrox/stackrox-mcp/internal/toolsets"
toolsetConfig "github.com/stackrox/stackrox-mcp/internal/toolsets/config"
toolsetVulnerability "github.com/stackrox/stackrox-mcp/internal/toolsets/vulnerability"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// getToolsets initializes and returns all available toolsets.
func getToolsets(cfg *config.Config, c *client.Client) []toolsets.Toolset {
return []toolsets.Toolset{
toolsetConfig.NewToolset(cfg, c),
toolsetVulnerability.NewToolset(cfg, c),
}
}

func TestGetToolsets(t *testing.T) {
allToolsets := getToolsets(&config.Config{}, &client.Client{})

Expand Down Expand Up @@ -46,7 +56,7 @@ func TestGracefulShutdown(t *testing.T) {
errChan := make(chan error, 1)

go func() {
errChan <- srv.Start(ctx)
errChan <- srv.Start(ctx, nil, nil)
}()

serverURL := "http://" + net.JoinHostPort(cfg.Server.Address, strconv.Itoa(cfg.Server.Port))
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/stretchr/testify v1.11.1
golang.stackrox.io/grpc-http1 v0.5.1
google.golang.org/grpc v1.79.2
google.golang.org/protobuf v1.36.10
)

require (
Expand Down Expand Up @@ -40,7 +41,6 @@ require (
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

Expand Down
29 changes: 29 additions & 0 deletions integration/fixtures.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//go:build integration

package integration

// Log4ShellFixture contains expected data from log4j_cve.json fixture.
var Log4ShellFixture = struct {
CVEName string
DeploymentCount int
DeploymentNames []string
}{
CVEName: "CVE-2021-44228",
DeploymentCount: 3,
DeploymentNames: []string{"elasticsearch", "kafka-broker", "spring-boot-app"},
}

// AllClustersFixture contains expected data from all_clusters.json fixture.
var AllClustersFixture = struct {
TotalCount int
ClusterNames []string
}{
TotalCount: 5,
ClusterNames: []string{
"production-cluster",
"staging-cluster",
"staging-central-cluster",
"development-cluster",
"production-cluster-eu",
},
}
183 changes: 183 additions & 0 deletions integration/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
//go:build integration

package integration

import (
"context"
"io"
"testing"
"time"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stackrox/stackrox-mcp/internal/app"
"github.com/stackrox/stackrox-mcp/internal/config"
"github.com/stackrox/stackrox-mcp/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// setupInitializedClient creates an initialized MCP client for testing.
func setupInitializedClient(t *testing.T) *testutil.MCPTestClient {
t.Helper()

client, err := createMCPClient(t)
require.NoError(t, err, "Failed to create MCP client")
t.Cleanup(func() { client.Close() })

return client
}

// callToolAndGetResult calls a tool and verifies it succeeds.
func callToolAndGetResult(t *testing.T, client *testutil.MCPTestClient, toolName string, args map[string]any) *mcp.CallToolResult {
t.Helper()

ctx := context.Background()
result, err := client.CallTool(ctx, toolName, args)
require.NoError(t, err)
testutil.RequireNoError(t, result)

return result
}

// getTextContent extracts text from the first content item.
func getTextContent(t *testing.T, result *mcp.CallToolResult) string {
t.Helper()
require.NotEmpty(t, result.Content, "should have content in response")

textContent, ok := result.Content[0].(*mcp.TextContent)
require.True(t, ok, "expected TextContent, got %T", result.Content[0])

return textContent.Text
}

// TestIntegration_ListTools verifies that all expected tools are registered.
func TestIntegration_ListTools(t *testing.T) {
client := setupInitializedClient(t)

ctx := context.Background()
result, err := client.ListTools(ctx)
require.NoError(t, err)

// Verify we have tools registered
assert.NotEmpty(t, result.Tools, "should have tools registered")

// Check for specific tools we expect
toolNames := make([]string, 0, len(result.Tools))
for _, tool := range result.Tools {
toolNames = append(toolNames, tool.Name)
}

assert.Contains(t, toolNames, "get_deployments_for_cve", "should have get_deployments_for_cve tool")
assert.Contains(t, toolNames, "list_clusters", "should have list_clusters tool")
}

// TestIntegration_ToolCalls tests successful tool calls using table-driven tests.
func TestIntegration_ToolCalls(t *testing.T) {
tests := map[string]struct {
toolName string
args map[string]any
expectedInText []string // strings that must appear in response
}{
"get_deployments_for_cve with Log4Shell": {
toolName: "get_deployments_for_cve",
args: map[string]any{"cveName": Log4ShellFixture.CVEName},
expectedInText: Log4ShellFixture.DeploymentNames,
},
"get_deployments_for_cve with non-existent CVE": {
toolName: "get_deployments_for_cve",
args: map[string]any{"cveName": "CVE-9999-99999"},
expectedInText: []string{`"deployments":[]`},
},
"list_clusters": {
toolName: "list_clusters",
args: map[string]any{},
expectedInText: AllClustersFixture.ClusterNames,
},
"get_clusters_with_orchestrator_cve": {
toolName: "get_clusters_with_orchestrator_cve",
args: map[string]any{"cveName": "CVE-2099-00001"},
expectedInText: []string{`"clusters":`},
},
}

for name, tt := range tests {
t.Run(name, func(t *testing.T) {
client := setupInitializedClient(t)
result := callToolAndGetResult(t, client, tt.toolName, tt.args)

responseText := getTextContent(t, result)
for _, expected := range tt.expectedInText {
assert.Contains(t, responseText, expected)
}
})
}
}

// TestIntegration_ToolCallErrors tests error handling using table-driven tests.
func TestIntegration_ToolCallErrors(t *testing.T) {
tests := map[string]struct {
toolName string
args map[string]any
expectedErrorMsg string
}{
"get_deployments_for_cve missing CVE name": {
toolName: "get_deployments_for_cve",
args: map[string]any{},
expectedErrorMsg: "cveName",
},
}

for name, tt := range tests {
t.Run(name, func(t *testing.T) {
client := setupInitializedClient(t)

ctx := context.Background()
_, err := client.CallTool(ctx, tt.toolName, tt.args)

// Validation errors are returned as protocol errors, not tool errors
require.Error(t, err, "should receive protocol error for invalid params")
assert.Contains(t, err.Error(), tt.expectedErrorMsg)
})
}
}

// createTestConfig creates a test configuration for the MCP server.
func createTestConfig() *config.Config {
return &config.Config{
Central: config.CentralConfig{
URL: "localhost:8081",
AuthType: "static",
APIToken: "test-token-admin",
InsecureSkipTLSVerify: true,
RequestTimeout: 30 * time.Second,
MaxRetries: 3,
InitialBackoff: time.Second,
MaxBackoff: 10 * time.Second,
},
Server: config.ServerConfig{
Type: config.ServerTypeStdio,
},
Tools: config.ToolsConfig{
Vulnerability: config.ToolsetVulnerabilityConfig{
Enabled: true,
},
ConfigManager: config.ToolConfigManagerConfig{
Enabled: true,
},
},
}
}

// createMCPClient is a helper function that creates an MCP client with the test configuration.
func createMCPClient(t *testing.T) (*testutil.MCPTestClient, error) {
t.Helper()

cfg := createTestConfig()

// Create a run function that wraps app.Run with the config
runFunc := func(ctx context.Context, stdin io.ReadCloser, stdout io.WriteCloser) error {
return app.Run(ctx, cfg, stdin, stdout)
}

return testutil.NewMCPTestClient(t, runFunc)
}
Loading
Loading