diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5aa033e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Git and jj files +.git/ +.jj/ +.gitignore +.gitattributes + +# CI/CD +.github/ + +# IDE and editor files +.vscode/ +.idea/ +.DS_Store + +# Test coverage output +/*.out + +# Build output +/stackrox-mcp + +# Lint output +/report.xml + +# Documentation +*.md +docs/ +LICENSE diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index de123ad..dd6674d 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -23,8 +23,6 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 - with: - go-version: '1.24' - name: Check code formatting run: make fmt-check @@ -33,3 +31,8 @@ jobs: uses: golangci/golangci-lint-action@v8 with: version: v2.6 + + - name: Run hadolint + uses: hadolint/hadolint-action@v3.3.0 + with: + dockerfile: Dockerfile diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 28f3d46..0ba9ac1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,8 +23,6 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 - with: - go-version: '1.24' - name: Download dependencies run: go mod download diff --git a/.golangci.yml b/.golangci.yml index 92003a5..eca4b29 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,7 +1,6 @@ version: "2" run: timeout: 240m - go: "1.24" modules-download-mode: readonly allow-parallel-runners: true output: diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c551c95 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# Multi-stage Dockerfile for StackRox MCP Server + +# Used images +ARG GOLANG_BUILDER=registry.access.redhat.com/ubi10/go-toolset:1.25 +ARG MCP_SERVER_BASE_IMAGE=registry.access.redhat.com/ubi10/ubi-micro:10.1 + +# Stage 1: Builder - Build the Go binary +FROM $GOLANG_BUILDER AS builder + +# Build arguments for multi-arch support +ARG TARGETOS=linux +ARG TARGETARCH=amd64 +ARG VERSION=dev + +# Set working directory +WORKDIR /workspace + +# Copy go module files first for better layer caching +COPY go.mod go.sum ./ + +# Download dependencies (cached layer) +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary with optimizations +# Output to "/tmp" directory, because user can not copy built binary to "/workspace" +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build \ + -ldflags="-w -s" \ + -trimpath \ + -o /tmp/stackrox-mcp \ + ./cmd/stackrox-mcp + +# Stage 2: Runtime - Minimal runtime image +FROM $MCP_SERVER_BASE_IMAGE + +# Set default environment variables +ENV LOG_LEVEL=INFO + +# Set working directory +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /tmp/stackrox-mcp /app/stackrox-mcp + +# Set ownership to non-root user +RUN chown -R 4000:4000 /app + +# Switch to non-root user +USER 4000 + +# Expose port for MCP server +EXPOSE 8080 + +# Run the application +ENTRYPOINT ["/app/stackrox-mcp"] diff --git a/Makefile b/Makefile index 86e8721..c5f48f4 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,9 @@ GOTEST=$(GOCMD) test GOFMT=$(GOCMD) fmt GOCLEAN=$(GOCMD) clean +# Set the container runtime command - prefer podman, fallback to docker +DOCKER_CMD = $(shell command -v podman >/dev/null 2>&1 && echo podman || echo docker) + # Build flags LDFLAGS=-ldflags "-X github.com/stackrox/stackrox-mcp/internal/server.version=$(VERSION)" @@ -35,6 +38,14 @@ help: ## Display this help message build: ## Build the binary $(GOBUILD) $(LDFLAGS) -o $(BINARY_NAME) ./cmd/stackrox-mcp +.PHONY: image +image: ## Build the docker image + $(DOCKER_CMD) build -t quay.io/stackrox-io/stackrox-mcp:$(VERSION) . + +.PHONY: dockerfile-lint +dockerfile-lint: ## Run hadolint for Dockerfile + $(DOCKER_CMD) run --rm -i --env HADOLINT_FAILURE_THRESHOLD=info ghcr.io/hadolint/hadolint < Dockerfile + .PHONY: test test: ## Run unit tests $(GOTEST) -v ./... diff --git a/README.md b/README.md index 508ddf3..5cab6c9 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,34 @@ You: "Can you list all the clusters from StackRox?" Claude: [Uses list_clusters tool to retrieve cluster information] ``` +## Docker + +### Building the Docker Image + +Build the image locally: +```bash +VERSION=dev make image +``` + +### Running the Container + +Run with default settings: +```bash +docker run --publish 8080:8080 --env STACKROX_MCP__TOOLS__CONFIG_MANAGER__ENABLED=true --env STACKROX_MCP__CENTRAL__URL= quay.io/stackrox-io/stackrox-mcp:dev +``` + +### Build Arguments + +- `TARGETOS` - Target operating system (default: `linux`) +- `TARGETARCH` - Target architecture (default: `amd64`) +- `VERSION` - Application version (default: `dev`) + +### Image Details + +- **Base Image**: Red Hat UBI10-micro (minimal, secure) +- **User**: Non-root user `mcp` (UID/GID 4000) +- **Port**: 8080 + ## Development For detailed development guidelines, testing standards, and contribution workflows, see [CONTRIBUTING.md](.github/CONTRIBUTING.md). diff --git a/go.mod b/go.mod index 7e59d79..9a901ec 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/stackrox/stackrox-mcp -go 1.24.0 - -toolchain go1.24.7 +go 1.25 require ( github.com/modelcontextprotocol/go-sdk v1.1.0 diff --git a/internal/server/server.go b/internal/server/server.go index f522210..4eca1db 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -53,17 +53,15 @@ func NewServer(cfg *config.Config, registry *toolsets.Registry) *Server { func (s *Server) Start(ctx context.Context) error { s.registerTools() - handler := mcp.NewStreamableHTTPHandler( - func(*http.Request) *mcp.Server { - return s.mcp - }, - nil, - ) + // Create a new ServeMux for routing. + mux := http.NewServeMux() + s.registerRouteHealth(mux) + s.registerRouteDefault(mux) addr := net.JoinHostPort(s.cfg.Server.Address, strconv.Itoa(s.cfg.Server.Port)) httpServer := &http.Server{ Addr: addr, - Handler: handler, + Handler: mux, ReadHeaderTimeout: readHeaderTimeout, } @@ -92,6 +90,29 @@ func (s *Server) Start(ctx context.Context) error { } } +func (s *Server) registerRouteHealth(mux *http.ServeMux) { + mux.HandleFunc("/health", func(responseWriter http.ResponseWriter, _ *http.Request) { + responseWriter.Header().Set("Content-Type", "application/json") + responseWriter.WriteHeader(http.StatusOK) + + _, err := responseWriter.Write([]byte(`{"status":"ok"}`)) + if err != nil { + slog.Error("Failed to write health response", "error", err) + } + }) +} + +func (s *Server) registerRouteDefault(mux *http.ServeMux) { + mcpHandler := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { + return s.mcp + }, + nil, + ) + + mux.Handle("/", mcpHandler) +} + // registerTools registers all tools from the registry with the MCP server. func (s *Server) registerTools() { slog.Info("Registering MCP tools") diff --git a/internal/server/server_test.go b/internal/server/server_test.go index ca11dfa..156b22a 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -166,3 +166,44 @@ func TestServer_Start(t *testing.T) { t.Fatal("Server did not shut down within timeout period") } } + +func TestServer_HealthEndpoint(t *testing.T) { + cfg := getDefaultConfig() + cfg.Server.Port = testutil.GetPortForTest(t) + + registry := toolsets.NewRegistry(cfg, []toolsets.Toolset{}) + srv := NewServer(cfg, registry) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + errChan := make(chan error, 1) + + go func() { + errChan <- srv.Start(ctx) + }() + + serverURL := "http://" + net.JoinHostPort(cfg.Server.Address, strconv.Itoa(cfg.Server.Port)) + err := testutil.WaitForServerReady(serverURL, 3*time.Second) + require.NoError(t, err, "Server should start within timeout") + + // Test health endpoint. + //nolint:noctx + resp, err := http.Get(serverURL + "/health") + require.NoError(t, err, "Health endpoint should be reachable") + require.NoError(t, resp.Body.Close()) + + assert.Equal(t, http.StatusOK, resp.StatusCode, "Health endpoint should return 200 OK") + assert.Equal(t, "application/json", resp.Header.Get("Content-Type"), "Health endpoint should return JSON") + + // Trigger shutdown. + cancel() + + // Wait for server to shut down. + select { + case <-errChan: + // Server shut down successfully + case <-time.After(ShutdownTimeout + time.Second): + t.Fatal("Server did not shut down within timeout period") + } +}