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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ bin/
.env
.vscode/
junit-report.xml
dist/
dist/
*.exe
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build go mod download
COPY . ./
# Build the server
RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${APP_VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o reportportal-mcp-server cmd/reportportal-mcp-server/main.go
-o reportportal-mcp-server cmd/main.go

# Make a stage to run the app
FROM gcr.io/distroless/base-debian12
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ git clone https://github.com/reportportal/reportportal-mcp-server.git
cd reportportal-mcp-server

# Build the binary
go build -o reportportal-mcp-server ./cmd/reportportal-mcp-server
go build -o reportportal-mcp-server ./cmd/main.go
```

This creates an executable called `reportportal-mcp-server`.
Expand Down
22 changes: 13 additions & 9 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ vars:
IMAGE_NAME: "reportportal-mcp-server"
IMAGE_NAME_DEBUG: "reportportal-mcp-server-debug"
INSPECTOR: "npx @modelcontextprotocol/inspector"
INSPECTOR_IMAGE: "node:20-alpine"
INSPECTOR_CMD: "npx -y @modelcontextprotocol/inspector"
DLV_PORT: '{{.DLV_PORT | default "52202"}}'
MCP_MODE: '{{.MCP_MODE | default "stdio"}}'
MCP_SERVER_PORT: '{{.MCP_SERVER_PORT | default "8080"}}'
Expand Down Expand Up @@ -53,14 +55,14 @@ tasks:
env:
CGO_ENABLED: 0
cmd: |
go build -o bin/reportportal-mcp-server cmd/reportportal-mcp-server/main.go
go build -o bin/reportportal-mcp-server cmd/main.go

app:build-debug:
desc: "Builds application with debug symbols for debugging"
env:
CGO_ENABLED: 0
cmd: |
go build -gcflags "all=-N -l" -o bin/reportportal-mcp-server-debug cmd/reportportal-mcp-server/main.go
go build -gcflags "all=-N -l" -o bin/reportportal-mcp-server-debug cmd/main.go

debug:stop:
desc: "Stops any running debug processes on port {{.DLV_PORT}}"
Expand All @@ -87,31 +89,33 @@ tasks:
cmd: "docker build -f debug.dockerfile -t {{.IMAGE_NAME_DEBUG}} ."

docker:run:
desc: "Runs docker mcp server in {{.MCP_MODE}} mode"
deps: [ docker:build ]
desc: "Runs docker mcp server in {{.MCP_MODE}} mode. For HTTP mode, server listens on port {{.MCP_SERVER_PORT}} and can be accessed via host.docker.internal:{{.MCP_SERVER_PORT}} from other containers."
deps: [docker:build]
cmd: >
{{if eq .MCP_MODE "http"}}
powershell -Command 'docker ps --no-trunc | Select-String ":{{.MCP_SERVER_PORT}}->" | ForEach-Object { ($_ -split "\s+")[0] } | ForEach-Object { docker stop $_ }; docker ps -q -f ancestor={{.IMAGE_NAME}} | ForEach-Object { docker stop $_ }';
docker run -i --rm -p {{.MCP_SERVER_PORT}}:{{.MCP_SERVER_PORT}} -e "RP_API_TOKEN=$RP_API_TOKEN" -e "RP_PROJECT=$RP_PROJECT" -e "RP_HOST=$RP_HOST" {{if .MCP_SERVER_HOST}}-e "MCP_SERVER_HOST={{.MCP_SERVER_HOST}}"{{end}} -e "MCP_SERVER_PORT={{.MCP_SERVER_PORT}}" -e "MCP_MODE={{.MCP_MODE}}" {{.IMAGE_NAME}}
{{else}}
powershell -Command "docker ps -q -f ancestor={{.IMAGE_NAME}} | ForEach-Object { docker stop $_ }";
docker run -i --rm -e "RP_API_TOKEN=$RP_API_TOKEN" -e "RP_PROJECT=$RP_PROJECT" -e "RP_HOST=$RP_HOST" -e "MCP_MODE={{.MCP_MODE}}" {{.IMAGE_NAME}}
{{end}}

inspector:
desc: "Runs inspector in production mode ({{.MCP_MODE}})"
deps: [ docker:build ]
desc: "Runs inspector in production mode ({{.MCP_MODE}}). For HTTP mode, inspector runs in Docker and connects to host.docker.internal:{{.MCP_SERVER_PORT}}/mcp. Ensure MCP server is running via 'task docker:run MCP_MODE=http' first."
deps: [docker:build]
cmd: >
{{if eq .MCP_MODE "http"}}
{{.INSPECTOR}} -- docker run -i --rm -p {{.MCP_SERVER_PORT}}:{{.MCP_SERVER_PORT}} -e "RP_API_TOKEN=$RP_API_TOKEN" -e "RP_PROJECT=$RP_PROJECT" -e "RP_HOST=$RP_HOST" {{if .MCP_SERVER_HOST}}-e "MCP_SERVER_HOST={{.MCP_SERVER_HOST}}"{{end}} -e "MCP_SERVER_PORT={{.MCP_SERVER_PORT}}" -e "MCP_MODE={{.MCP_MODE}}" {{.IMAGE_NAME}}
docker run -i --rm -p 6274:6274 -p 6277:6277 --add-host=host.docker.internal:host-gateway {{.INSPECTOR_IMAGE}} sh -c "{{.INSPECTOR_CMD}} http://host.docker.internal:{{.MCP_SERVER_PORT}}/mcp"
{{else}}
{{.INSPECTOR}} -- docker run -i --rm -e "RP_API_TOKEN=$RP_API_TOKEN" -e "RP_PROJECT=$RP_PROJECT" -e "RP_HOST=$RP_HOST" -e "MCP_MODE={{.MCP_MODE}}" {{.IMAGE_NAME}}
{{end}}

inspector-debug:
desc: "Runs inspector with the MCP server in debug mode ({{.MCP_MODE}})"
desc: "Runs inspector with the MCP server in debug mode ({{.MCP_MODE}}). For HTTP mode, inspector runs in Docker and connects to host.docker.internal:{{.MCP_SERVER_PORT}}/mcp. Ensure debug server is running separately first."
deps: [ docker:build-debug ]
cmd: >
{{if eq .MCP_MODE "http"}}
{{.INSPECTOR}} -- docker run -p {{.DLV_PORT}}:{{.DLV_PORT}} -p {{.MCP_SERVER_PORT}}:{{.MCP_SERVER_PORT}} -i --rm -e "RP_API_TOKEN=$RP_API_TOKEN" -e "RP_PROJECT=$RP_PROJECT" -e "RP_HOST=$RP_HOST" {{if .MCP_SERVER_HOST}}-e "MCP_SERVER_HOST={{.MCP_SERVER_HOST}}"{{end}} -e "MCP_SERVER_PORT={{.MCP_SERVER_PORT}}" -e "MCP_MODE={{.MCP_MODE}}" {{.IMAGE_NAME_DEBUG}}
docker run -i --rm -p 6274:6274 -p 6277:6277 --add-host=host.docker.internal:host-gateway {{.INSPECTOR_IMAGE}} sh -c "{{.INSPECTOR_CMD}} http://host.docker.internal:{{.MCP_SERVER_PORT}}/mcp"
{{else}}
{{.INSPECTOR}} -- docker run -p {{.DLV_PORT}}:{{.DLV_PORT}} -i --rm -e "RP_API_TOKEN=$RP_API_TOKEN" -e "RP_PROJECT=$RP_PROJECT" -e "RP_HOST=$RP_HOST" -e "MCP_MODE={{.MCP_MODE}}" {{.IMAGE_NAME_DEBUG}}
{{end}}
38 changes: 38 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package main

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

"github.com/reportportal/reportportal-mcp-server/internal/config"
)

var (
version = "version" // Application version
commit = "commit" // Git commit hash
date = "date" // Build date
)

func main() {
// Create a context that listens for OS interrupt or termination signals
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

// Create application version info
appVersion := config.AppVersion{
Version: version,
Commit: commit,
Date: date,
}

// Run the application using the config module
if err := config.RunApp(ctx, appVersion); err != nil {
slog.Error("application error", "error", err)
// Explicitly stop the signal context on error
stop()
os.Exit(1)
}
}
2 changes: 1 addition & 1 deletion debug.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build go mod download
COPY . ./
# Build the server
RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -gcflags "all=-N -l" -ldflags="-X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o reportportal-mcp-server cmd/reportportal-mcp-server/main.go
-o reportportal-mcp-server cmd/main.go

# Final runtime image
FROM gcr.io/distroless/base-debian12
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package mcpreportportal
package analytics

import (
"bytes"
Expand All @@ -17,6 +17,8 @@ import (

"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"

"github.com/reportportal/reportportal-mcp-server/internal/middleware"
)

const (
Expand Down Expand Up @@ -109,6 +111,7 @@ type Analytics struct {
stopChan chan struct{}
wg sync.WaitGroup
tickerDone chan struct{}
stopped int32 // atomic flag: 0 = running, 1 = stopped
}

// NewAnalytics creates a new Analytics instance
Expand Down Expand Up @@ -198,7 +201,7 @@ func (a *Analytics) getUserIDFromContext(ctx context.Context) string {
}

// If no env var token/user ID was set (anonymous mode), try to get token from context
if token, ok := GetTokenFromContext(ctx); ok && token != "" {
if token, ok := middleware.GetTokenFromContext(ctx); ok && token != "" {
// Hash the Bearer token to get a secure user identifier
hashedToken := HashToken(token)
slog.Debug("Using Bearer token from request for analytics", "source", "bearer_header")
Expand Down Expand Up @@ -363,11 +366,19 @@ func (a *Analytics) startMetricsProcessor() {
}

// Stop gracefully shuts down the analytics system
// Stop is idempotent and safe to call multiple times
func (a *Analytics) Stop() {
if a == nil || a.stopChan == nil {
return
}

// Use atomic CAS to ensure Stop only executes once
// Compare-and-swap: if stopped is 0, set it to 1 and proceed; otherwise return
if !atomic.CompareAndSwapInt32(&a.stopped, 0, 1) {
// Already stopped, return early
return
}

slog.Debug("Stopping analytics metrics processor")
close(a.stopChan)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package mcpreportportal
package analytics

import (
"bytes"
Expand All @@ -13,6 +13,8 @@ import (
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/reportportal/reportportal-mcp-server/internal/middleware"
)

// Test constants
Expand Down Expand Up @@ -574,7 +576,7 @@ func TestGetUserIDFromContext(t *testing.T) {
// Create context with or without Bearer token
ctx := context.Background()
if tt.tokenInContext != "" {
ctx = WithTokenInContext(ctx, tt.tokenInContext)
ctx = middleware.WithTokenInContext(ctx, tt.tokenInContext)
}

// Get user ID from context
Expand Down Expand Up @@ -623,7 +625,7 @@ func TestTrackMCPEventWithTokenFromContext(t *testing.T) {

// Track event with Bearer token in context
bearerToken := testToken1
ctx := WithTokenInContext(context.Background(), bearerToken)
ctx := middleware.WithTokenInContext(context.Background(), bearerToken)

analytics.TrackMCPEvent(ctx, "test_tool_1")

Expand Down Expand Up @@ -659,7 +661,7 @@ func TestTrackMCPEventWithTokenFromContext(t *testing.T) {

// Track event with different Bearer tokens
token1 := testToken1
ctx1 := WithTokenInContext(context.Background(), token1)
ctx1 := middleware.WithTokenInContext(context.Background(), token1)

analytics.TrackMCPEvent(ctx1, "test_tool_1")

Expand All @@ -674,7 +676,7 @@ func TestTrackMCPEventWithTokenFromContext(t *testing.T) {

// Track with different Bearer token
token2 := testToken2
ctx2 := WithTokenInContext(context.Background(), token2)
ctx2 := middleware.WithTokenInContext(context.Background(), token2)

analytics.TrackMCPEvent(ctx2, "test_tool_2")

Expand Down Expand Up @@ -732,8 +734,8 @@ func TestAnalyticsBatchSendingPerUser(t *testing.T) {
token1 := testToken1
token2 := testToken2

ctx1 := WithTokenInContext(context.Background(), token1)
ctx2 := WithTokenInContext(context.Background(), token2)
ctx1 := middleware.WithTokenInContext(context.Background(), token1)
ctx2 := middleware.WithTokenInContext(context.Background(), token2)

// Track multiple events
analytics.TrackMCPEvent(ctx1, "tool_a")
Expand Down Expand Up @@ -769,8 +771,8 @@ func TestAnalyticsBatchSendingPerUser(t *testing.T) {
token1 := testToken1
token2 := testToken2

ctx1 := WithTokenInContext(context.Background(), token1)
ctx2 := WithTokenInContext(context.Background(), token2)
ctx1 := middleware.WithTokenInContext(context.Background(), token1)
ctx2 := middleware.WithTokenInContext(context.Background(), token2)

// Track multiple events
analytics.TrackMCPEvent(ctx1, "tool_a")
Expand Down Expand Up @@ -821,7 +823,7 @@ func TestAnalyticsHashingComparison_WithAndWithoutRPToken(t *testing.T) {
defer analytics2.Stop()

// Create context with Bearer token (same for both)
ctxWithBearer := WithTokenInContext(context.Background(), bearerToken)
ctxWithBearer := middleware.WithTokenInContext(context.Background(), bearerToken)

// Get user IDs from both analytics instances
userID1 := analytics1.getUserIDFromContext(ctxWithBearer)
Expand Down Expand Up @@ -902,7 +904,7 @@ func TestSameTokenDifferentSources_ProducesSameHash(t *testing.T) {
defer analytics2.Stop()

// Create context with the SAME token value in Bearer header
ctxWithBearer := WithTokenInContext(context.Background(), sameTokenValue)
ctxWithBearer := middleware.WithTokenInContext(context.Background(), sameTokenValue)

// Get user IDs from both scenarios
userID1 := analytics1.getUserIDFromContext(context.Background()) // Uses env var
Expand Down Expand Up @@ -972,15 +974,15 @@ func TestHTTPTokenMiddlewareIntegrationWithAnalytics(t *testing.T) {
})

// Wrap with HTTPTokenMiddleware
middleware := HTTPTokenMiddleware(testHandler)
handler := middleware.HTTPTokenMiddleware(testHandler)

// Request with Bearer token
token := testToken1
req1 := httptest.NewRequest("POST", "/test", nil)
req1.Header.Set("Authorization", "Bearer "+token)

rr1 := httptest.NewRecorder()
middleware.ServeHTTP(rr1, req1)
handler.ServeHTTP(rr1, req1)

assert.Equal(t, http.StatusOK, rr1.Code)

Expand All @@ -998,7 +1000,7 @@ func TestHTTPTokenMiddlewareIntegrationWithAnalytics(t *testing.T) {
req2 := httptest.NewRequest("POST", "/test", nil)

rr2 := httptest.NewRecorder()
middleware.ServeHTTP(rr2, req2)
handler.ServeHTTP(rr2, req2)

assert.Equal(t, http.StatusOK, rr2.Code)

Expand Down Expand Up @@ -1032,15 +1034,15 @@ func TestHTTPTokenMiddlewareIntegrationWithAnalytics(t *testing.T) {
w.WriteHeader(http.StatusOK)
})

middleware := HTTPTokenMiddleware(testHandler)
handler := middleware.HTTPTokenMiddleware(testHandler)

// Request with Bearer token (should be ignored)
bearerToken := testToken1
req := httptest.NewRequest("POST", "/test", nil)
req.Header.Set("Authorization", "Bearer "+bearerToken)

rr := httptest.NewRecorder()
middleware.ServeHTTP(rr, req)
handler.ServeHTTP(rr, req)

assert.Equal(t, http.StatusOK, rr.Code)

Expand Down
Loading
Loading