Skip to content
Merged
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
27 changes: 27 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions .github/workflows/style.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
2 changes: 0 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
version: "2"
run:
timeout: 240m
go: "1.24"
modules-download-mode: readonly
allow-parallel-runners: true
output:
Expand Down
58 changes: 58 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
11 changes: 11 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)"

Expand All @@ -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 ./...
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<central host:port> 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).
Expand Down
4 changes: 1 addition & 3 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
35 changes: 28 additions & 7 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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")
Expand Down
41 changes: 41 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading