Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1d35a3d
ROX-31495: wiremock for central (#32)
janisz Mar 2, 2026
3c617df
ci: fix golangci-lint style issues
janisz Mar 6, 2026
b3fcf5c
refactor: extract helper methods to fix funlen linter issue
janisz Mar 6, 2026
630b3da
fix
janisz Mar 9, 2026
e911b97
fix
janisz Mar 9, 2026
8550a0c
fix
janisz Mar 9, 2026
07f2802
fix: export GetToolsets function for reusability
janisz Mar 17, 2026
6424c85
fix: remove duplicate getToolsets and use app.GetToolsets
janisz Mar 17, 2026
c727323
test: move comprehensive TestGetToolsets to app package
janisz Mar 17, 2026
f3c3f8e
docs: remove irrelevant stdin/stdout comment from app.go
janisz Mar 17, 2026
4aadc98
docs: remove '(production mode)' annotation from server.go
janisz Mar 17, 2026
8e73225
docs: remove '(for testing)' annotation from server.go
janisz Mar 17, 2026
46bb0fb
docs: remove '(production)' annotation from server.go
janisz Mar 17, 2026
25d1d91
docs: add full paths to fixture comments
janisz Mar 17, 2026
c79028d
refactor: move helper functions to top of integration_test.go
janisz Mar 17, 2026
8b90a9b
refactor: simplify RequireNoError with early return pattern
janisz Mar 17, 2026
afc96aa
chore: delete unused RunCommand function
janisz Mar 17, 2026
6708e6b
fix: create cancellable context before client connection
janisz Mar 17, 2026
46d7709
refactor: consolidate integration test helpers in testutil
janisz Mar 17, 2026
b6c5b1d
test: replace substring matching with strict JSON equality in integra…
janisz Mar 23, 2026
f40166c
Update internal/app/app.go
janisz Mar 23, 2026
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
92 changes: 92 additions & 0 deletions integration/fixtures.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//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
ExpectedJSON string
}{
CVEName: "CVE-2021-44228",
DeploymentCount: 3,
DeploymentNames: []string{"elasticsearch", "kafka-broker", "spring-boot-app"},
ExpectedJSON: `{
"deployments": [
{
"name": "elasticsearch",
"namespace": "logging",
"clusterId": "cluster-prod-01",
"clusterName": "production-cluster"
},
{
"name": "kafka-broker",
"namespace": "messaging",
"clusterId": "cluster-prod-02",
"clusterName": "production-cluster-eu"
},
{
"name": "spring-boot-app",
"namespace": "applications",
"clusterId": "cluster-staging-01",
"clusterName": "staging-cluster"
}
],
"nextCursor": ""
}`,
}

// AllClustersFixture contains expected data from wiremock/fixtures/clusters/all_clusters.json fixture.
var AllClustersFixture = struct {
TotalCount int
ClusterNames []string
ExpectedJSON string
}{
TotalCount: 5,
ClusterNames: []string{
"production-cluster",
"staging-cluster",
"staging-central-cluster",
"development-cluster",
"production-cluster-eu",
},
ExpectedJSON: `{
"clusters": [
{
"id": "cluster-prod-01",
"name": "production-cluster",
"type": "KUBERNETES_CLUSTER"
},
{
"id": "cluster-staging-01",
"name": "staging-cluster",
"type": "KUBERNETES_CLUSTER"
},
{
"id": "65673bd7-da6a-4cdc-a5fc-95765d1b9724",
"name": "staging-central-cluster",
"type": "OPENSHIFT4_CLUSTER"
},
{
"id": "cluster-dev-01",
"name": "development-cluster",
"type": "KUBERNETES_CLUSTER"
},
{
"id": "cluster-prod-02",
"name": "production-cluster-eu",
"type": "KUBERNETES_CLUSTER"
}
],
"totalCount": 5,
"offset": 0,
"limit": 0
}`,
}

// EmptyDeploymentsJSON represents the expected JSON response when no deployments are found.
const EmptyDeploymentsJSON = `{"deployments": [], "nextCursor": ""}`

// EmptyClustersForCVEJSON represents the expected JSON response from get_clusters_with_orchestrator_cve when no clusters are found.
const EmptyClustersForCVEJSON = `{"clusters": []}`
101 changes: 101 additions & 0 deletions integration/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//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
expectedJSON string // expected JSON response for exact comparison
}{
"get_deployments_for_cve with Log4Shell": {
toolName: "get_deployments_for_cve",
args: map[string]any{"cveName": Log4ShellFixture.CVEName},
expectedJSON: Log4ShellFixture.ExpectedJSON,
},
"get_deployments_for_cve with non-existent CVE": {
toolName: "get_deployments_for_cve",
args: map[string]any{"cveName": "CVE-9999-99999"},
expectedJSON: EmptyDeploymentsJSON,
},
"list_clusters": {
toolName: "list_clusters",
args: map[string]any{},
expectedJSON: AllClustersFixture.ExpectedJSON,
},
"get_clusters_with_orchestrator_cve": {
toolName: "get_clusters_with_orchestrator_cve",
args: map[string]any{"cveName": "CVE-2099-00001"},
expectedJSON: EmptyClustersForCVEJSON,
},
}

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)
assert.JSONEq(t, tt.expectedJSON, responseText)
})
}
}

// 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)
})
}
}
Loading
Loading