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:
files: ./coverage-integration.out
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
flags: integration
name: integration-tests
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ test-coverage-and-junit: ## Run unit tests with coverage and junit output
go install github.com/jstemmer/go-junit-report/v2@v2.1.0
$(GOTEST) -v -cover -race -coverprofile=$(COVERAGE_OUT) ./... 2>&1 | go-junit-report -set-exit-code -iocopy -out $(JUNIT_OUT)

.PHONY: test-integration-coverage
test-integration-coverage: ## Run integration tests with coverage
$(GOTEST) -v -cover -race -tags=integration -coverprofile=coverage-integration.out ./integration

.PHONY: coverage-html
coverage-html: test ## Generate and open HTML coverage report
$(GOCMD) tool cover -html=$(COVERAGE_OUT)
Expand Down
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)
}
}
18 changes: 3 additions & 15 deletions cmd/stackrox-mcp/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,15 @@ import (
"testing"
"time"

"github.com/stackrox/stackrox-mcp/internal/app"
"github.com/stackrox/stackrox-mcp/internal/client"
"github.com/stackrox/stackrox-mcp/internal/config"
"github.com/stackrox/stackrox-mcp/internal/server"
"github.com/stackrox/stackrox-mcp/internal/testutil"
"github.com/stackrox/stackrox-mcp/internal/toolsets"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

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

toolsetNames := []string{}
for _, toolset := range allToolsets {
toolsetNames = append(toolsetNames, toolset.GetName())
}

assert.Contains(t, toolsetNames, "config_manager")
assert.Contains(t, toolsetNames, "vulnerability")
}

func TestGracefulShutdown(t *testing.T) {
// Set up minimal valid config. config.LoadConfig() validates configuration.
t.Setenv("STACKROX_MCP__TOOLS__VULNERABILITY__ENABLED", "true")
Expand All @@ -39,14 +27,14 @@ func TestGracefulShutdown(t *testing.T) {
require.NotNil(t, cfg)
cfg.Server.Port = testutil.GetPortForTest(t)

registry := toolsets.NewRegistry(cfg, getToolsets(cfg, &client.Client{}))
registry := toolsets.NewRegistry(cfg, app.GetToolsets(cfg, &client.Client{}))
srv := server.NewServer(cfg, registry)
ctx, cancel := context.WithCancel(context.Background())

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
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 wiremock/fixtures/deployments/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 wiremock/fixtures/clusters/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",
},
}
103 changes: 103 additions & 0 deletions integration/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//go:build integration

package integration

import (
"context"
"testing"

"github.com/stackrox/stackrox-mcp/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

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

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 := testutil.SetupInitializedClient(t, testutil.CreateIntegrationMCPClient)
result := testutil.CallToolAndGetResult(t, client, tt.toolName, tt.args)

responseText := testutil.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 := testutil.SetupInitializedClient(t, testutil.CreateIntegrationMCPClient)

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)
})
}
}
66 changes: 66 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Package app contains the main application logic for the stackrox-mcp server.
// This is separated from the main package to allow tests to run the server in-process.
package app

import (
"context"
"io"
"log/slog"
"os"
"os/signal"
"syscall"

"github.com/pkg/errors"
"github.com/stackrox/stackrox-mcp/internal/client"
"github.com/stackrox/stackrox-mcp/internal/config"
"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),
}
}

// Run executes the MCP server with the given configuration and I/O streams.
// This function is extracted from main() to allow tests to run the server in-process.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: I think, this is not relevant information

Suggested change
// This function is extracted from main() to allow tests to run the server in-process.

func Run(ctx context.Context, cfg *config.Config, stdin io.ReadCloser, stdout io.WriteCloser) error {
// Log full configuration with sensitive data redacted.
slog.Info("Configuration loaded successfully", "config", cfg.Redacted())

// Create a cancellable context for the entire server lifecycle
ctx, cancel := context.WithCancel(ctx)
defer cancel()

// Set up signal handling for graceful shutdown.
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

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

stackroxClient, err := client.NewClient(&cfg.Central)
if err != nil {
return errors.Wrap(err, "failed to create client")
}

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

err = stackroxClient.Connect(ctx)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before, we have also used cancelable context here. Is there a reason why we are not doing that anymore?

if err != nil {
return errors.Wrap(err, "failed to connect to central")
}

slog.Info("Starting StackRox MCP server")

return errors.Wrap(srv.Start(ctx, stdin, stdout), "failed to start server")
}
Loading
Loading