diff --git a/.gitignore b/.gitignore index a9a9d5f..3689074 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ bin/ .env .vscode/ junit-report.xml -dist/ \ No newline at end of file +dist/ +*.exe \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f5107cb..f8cd590 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 09edfd0..ef63fa0 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/Taskfile.yaml b/Taskfile.yaml index 1cd3da5..1b04b3e 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -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"}}' @@ -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}}" @@ -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}} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..3ce066a --- /dev/null +++ b/cmd/main.go @@ -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) + } +} diff --git a/debug.dockerfile b/debug.dockerfile index efa52c6..51117e8 100644 --- a/debug.dockerfile +++ b/debug.dockerfile @@ -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 diff --git a/internal/reportportal/analytics.go b/internal/analytics/analytics.go similarity index 96% rename from internal/reportportal/analytics.go rename to internal/analytics/analytics.go index 88d0a8d..0313c86 100644 --- a/internal/reportportal/analytics.go +++ b/internal/analytics/analytics.go @@ -1,4 +1,4 @@ -package mcpreportportal +package analytics import ( "bytes" @@ -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 ( @@ -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 @@ -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") @@ -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) diff --git a/internal/reportportal/analytics_test.go b/internal/analytics/analytics_test.go similarity index 96% rename from internal/reportportal/analytics_test.go rename to internal/analytics/analytics_test.go index 661f764..c38cbc0 100644 --- a/internal/reportportal/analytics_test.go +++ b/internal/analytics/analytics_test.go @@ -1,4 +1,4 @@ -package mcpreportportal +package analytics import ( "bytes" @@ -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 @@ -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 @@ -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") @@ -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") @@ -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") @@ -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") @@ -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") @@ -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) @@ -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 @@ -972,7 +974,7 @@ func TestHTTPTokenMiddlewareIntegrationWithAnalytics(t *testing.T) { }) // Wrap with HTTPTokenMiddleware - middleware := HTTPTokenMiddleware(testHandler) + handler := middleware.HTTPTokenMiddleware(testHandler) // Request with Bearer token token := testToken1 @@ -980,7 +982,7 @@ func TestHTTPTokenMiddlewareIntegrationWithAnalytics(t *testing.T) { req1.Header.Set("Authorization", "Bearer "+token) rr1 := httptest.NewRecorder() - middleware.ServeHTTP(rr1, req1) + handler.ServeHTTP(rr1, req1) assert.Equal(t, http.StatusOK, rr1.Code) @@ -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) @@ -1032,7 +1034,7 @@ 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 @@ -1040,7 +1042,7 @@ func TestHTTPTokenMiddlewareIntegrationWithAnalytics(t *testing.T) { req.Header.Set("Authorization", "Bearer "+bearerToken) rr := httptest.NewRecorder() - middleware.ServeHTTP(rr, req) + handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) diff --git a/cmd/reportportal-mcp-server/main.go b/internal/config/cli.go similarity index 61% rename from cmd/reportportal-mcp-server/main.go rename to internal/config/cli.go index 92c89b1..5e40e4e 100644 --- a/cmd/reportportal-mcp-server/main.go +++ b/internal/config/cli.go @@ -1,183 +1,133 @@ -package main +package config import ( "context" "errors" "fmt" "io" - "log" "log/slog" "net/http" "net/url" "os" - "os/signal" "runtime" "strings" - "syscall" "time" "github.com/mark3labs/mcp-go/server" "github.com/urfave/cli/v3" - mcpreportportal "github.com/reportportal/reportportal-mcp-server/internal/reportportal" + "github.com/reportportal/reportportal-mcp-server/internal/analytics" + httpserver "github.com/reportportal/reportportal-mcp-server/internal/http" + "github.com/reportportal/reportportal-mcp-server/internal/mcp_handlers" + "github.com/reportportal/reportportal-mcp-server/internal/utils" ) -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() - - // Get MCP mode from environment variable, default to stdio - rawMcpMode := strings.ToLower(os.Getenv("MCP_MODE")) - slog.Debug("MCP_MODE env variable is set to: " + rawMcpMode) - mcpMode := strings.ToLower(rawMcpMode) - if mcpMode == "" { - mcpMode = "stdio" - } +// AppVersion holds build-time version information +type AppVersion struct { + Version string + Commit string + Date string +} - // Common flags for all modes - commonFlags := []cli.Flag{ +// GetCommonFlags returns the common CLI flags used by all server modes (both stdio and http) +func GetCommonFlags() []cli.Flag { + return []cli.Flag{ &cli.StringFlag{ Name: "rp-host", Required: true, Sources: cli.EnvVars("RP_HOST"), - Usage: "ReportPortal host URL", + Usage: "[GLOBAL/REQUIRED] ReportPortal host URL", }, &cli.StringFlag{ Name: "project", Required: false, Sources: cli.EnvVars("RP_PROJECT"), Value: "", - Usage: "ReportPortal project name", + Usage: "[GLOBAL/OPTIONAL] ReportPortal project name", }, &cli.StringFlag{ Name: "log-level", Required: false, Sources: cli.EnvVars("LOG_LEVEL"), Value: slog.LevelInfo.String(), - Usage: "Logging level", + Usage: "[GLOBAL/OPTIONAL] Logging level (DEBUG, INFO, WARN, ERROR)", }, &cli.StringFlag{ Name: "user-id", Required: false, Sources: cli.EnvVars("RP_USER_ID"), Value: "", - Usage: "Custom user ID for analytics (used for analytics identification)", + Usage: "[GLOBAL/OPTIONAL] Custom user ID for analytics (used for analytics identification)", }, &cli.BoolFlag{ Name: "analytics-off", Required: false, Sources: cli.EnvVars("RP_MCP_ANALYTICS_OFF"), - Usage: "Disable Google Analytics tracking", + Usage: "[GLOBAL/OPTIONAL] Disable Google Analytics tracking", Value: false, }, } +} - // stdio-specific flags (only included when MCP_MODE is stdio) - stdioFlags := []cli.Flag{ - &cli.StringFlag{ - Name: "token", - Required: false, // Will be validated as required in runStdioServer - Sources: cli.EnvVars("RP_API_TOKEN"), - Usage: "API token for authentication (required for stdio mode)", - }, - } - - // HTTP-specific flags (only included when MCP_MODE is http) - httpFlags := []cli.Flag{ +// GetHTTPFlags returns additional flags specific to HTTP mode only (not available in stdio mode) +func GetHTTPFlags() []cli.Flag { + return []cli.Flag{ &cli.IntFlag{ Name: "port", Required: false, Sources: cli.EnvVars("MCP_SERVER_PORT"), - Usage: "HTTP server port", + Usage: "[HTTP-ONLY] HTTP server port", Value: 8080, }, &cli.StringFlag{ Name: "host", Required: false, Sources: cli.EnvVars("MCP_SERVER_HOST"), - Usage: "HTTP bind host/interface (e.g., 0.0.0.0, 127.0.0.1, ::)", + Usage: "[HTTP-ONLY] HTTP bind host/interface (e.g., 0.0.0.0, 127.0.0.1, ::)", Value: "", }, &cli.IntFlag{ Name: "max-workers", Required: false, Sources: cli.EnvVars("RP_MAX_WORKERS"), - Usage: "Maximum number of worker goroutines (0 = auto-detect as CPU count * 2)", + Usage: "[HTTP-ONLY] Maximum number of worker goroutines (0 = auto-detect as CPU count * 2)", Value: 0, }, &cli.IntFlag{ Name: "connection-timeout", Required: false, Sources: cli.EnvVars("RP_CONNECTION_TIMEOUT"), - Usage: "Connection timeout in seconds", + Usage: "[HTTP-ONLY] Connection timeout in seconds", Value: 30, }, } +} - // Build flags based on MCP mode - var allFlags []cli.Flag - allFlags = append(allFlags, commonFlags...) - if mcpMode == "http" { - allFlags = append(allFlags, httpFlags...) - } else { - // stdio mode (default) - add stdio-specific flags - allFlags = append(allFlags, stdioFlags...) - } - - // Define the CLI command structure - cmd := &cli.Command{ - Version: fmt.Sprintf("%s (%s) %s", version, commit, date), - Description: `ReportPortal MCP Server - -ENVIRONMENT VARIABLES: - MCP_MODE Server mode: "stdio" (default) or "http" - Controls which server type to run and which flags are available - -AUTHENTICATION: - stdio mode: RP_API_TOKEN is REQUIRED (must be set via environment variable or --token flag) - http mode: RP_API_TOKEN and --token are COMPLETELY IGNORED - Tokens MUST be passed per-request via 'Authorization: Bearer ' header - -ANALYTICS: - stdio mode: RP_API_TOKEN is required for analytics (used for secure user identification) - http mode: Analytics uses RP_USER_ID env var for identification - Use --analytics-off or RP_MCP_ANALYTICS_OFF=true to disable analytics`, - Flags: allFlags, - Before: initLogger(), - Action: func(ctx context.Context, cmd *cli.Command) error { - // Check mcpMode and run appropriate server - switch mcpMode { - case "http": - return runStreamingServer(ctx, cmd) - case "stdio": - return runStdioServer(ctx, cmd) - default: - slog.Info( - "unknown MCP_MODE, defaulting to stdio", - "mode", - mcpMode, - "supported", - "stdio, http", - ) - return runStdioServer(ctx, cmd) - } +// GetStdioFlags returns flags specific to stdio mode only +func GetStdioFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "token", + Required: false, // Will be validated as required in runStdioServer + Sources: cli.EnvVars("RP_API_TOKEN"), + Usage: "[STDIO-ONLY] API token for authentication (required for stdio mode)", }, } +} - // Run the CLI command and handle any errors - if err := cmd.Run(ctx, os.Args); err != nil { - log.Fatal(err) +// GetMCPMode returns the MCP mode from environment variable, defaults to "stdio" +func GetMCPMode() string { + rawMcpMode := strings.ToLower(os.Getenv("MCP_MODE")) + slog.Debug("MCP_MODE env variable is set to: " + rawMcpMode) + mcpMode := strings.ToLower(rawMcpMode) + if mcpMode == "" { + mcpMode = "stdio" } + return mcpMode } -func initLogger() func(ctx context.Context, command *cli.Command) (context.Context, error) { +// InitLogger returns a CLI before function that initializes logging +func InitLogger() func(ctx context.Context, command *cli.Command) (context.Context, error) { return func(ctx context.Context, command *cli.Command) (context.Context, error) { // Set up default logging configuration var logLevel slog.Level @@ -197,33 +147,17 @@ func initLogger() func(ctx context.Context, command *cli.Command) (context.Conte } } -// handleServerError processes server errors, distinguishing between graceful shutdowns and actual errors. -// Returns nil for graceful shutdowns, or the original error for actual problems. -func handleServerError(err error, analytics *mcpreportportal.Analytics, serverType string) error { - // Check for successful completion or expected shutdown errors - if err == nil || - errors.Is(err, http.ErrServerClosed) || - errors.Is(err, context.Canceled) || - errors.Is(err, context.DeadlineExceeded) { - slog.Info("server shutdown completed", "type", serverType) - mcpreportportal.StopAnalytics(analytics, "") - return nil - } - - slog.Error("server error occurred", "type", serverType, "error", err) - mcpreportportal.StopAnalytics(analytics, "server error") - return fmt.Errorf("error running %s server: %w", serverType, err) -} - -// buildHTTPServerConfig creates HTTPServerConfig from CLI flags with smart defaults. -// This replaces the removed GetProductionConfig/GetHighTrafficConfig factory functions. -func buildHTTPServerConfig(cmd *cli.Command) (mcpreportportal.HTTPServerConfig, error) { +// BuildHTTPServerConfig creates HTTPServerConfig from CLI flags with smart defaults. +func BuildHTTPServerConfig( + cmd *cli.Command, + appVersion AppVersion, +) (httpserver.HTTPServerConfig, error) { // Retrieve required parameters from CLI flags host := cmd.String("rp-host") // Note: RP_API_TOKEN and --token flag are not available in HTTP mode // Tokens MUST come from HTTP request headers (Authorization: Bearer ) userID := cmd.String("user-id") - analyticsAPISecret := mcpreportportal.GetAnalyticArg() + analyticsAPISecret := analytics.GetAnalyticArg() analyticsOff := cmd.Bool("analytics-off") // Performance tuning parameters with defaults @@ -237,11 +171,16 @@ func buildHTTPServerConfig(cmd *cli.Command) (mcpreportportal.HTTPServerConfig, hostUrl, err := url.Parse(host) if err != nil { - return mcpreportportal.HTTPServerConfig{}, fmt.Errorf("invalid host URL: %w", err) + return httpserver.HTTPServerConfig{}, fmt.Errorf("invalid host URL: %w", err) } - return mcpreportportal.HTTPServerConfig{ - Version: fmt.Sprintf("%s (%s) %s", version, commit, date), + return httpserver.HTTPServerConfig{ + Version: fmt.Sprintf( + "%s (%s) %s", + appVersion.Version, + appVersion.Commit, + appVersion.Date, + ), HostURL: hostUrl, FallbackRPToken: "", // Always empty - RP_API_TOKEN is not available in HTTP mode UserID: userID, @@ -252,14 +191,18 @@ func buildHTTPServerConfig(cmd *cli.Command) (mcpreportportal.HTTPServerConfig, }, nil } -func newMCPServer(cmd *cli.Command) (*server.MCPServer, *mcpreportportal.Analytics, error) { +// NewMCPServer creates a new MCP server from CLI command configuration +func NewMCPServer( + cmd *cli.Command, + appVersion AppVersion, +) (*server.MCPServer, *analytics.Analytics, error) { // Retrieve required parameters from the command flags - token := cmd.String("token") // API token - host := cmd.String("rp-host") // ReportPortal host URL - userID := cmd.String("user-id") // Unified user ID for analytics - project := cmd.String("project") // ReportPortal project name - analyticsAPISecret := mcpreportportal.GetAnalyticArg() // Analytics API secret - analyticsOff := cmd.Bool("analytics-off") // Disable analytics flag + token := cmd.String("token") // API token + host := cmd.String("rp-host") // ReportPortal host URL + userID := cmd.String("user-id") // Unified user ID for analytics + project := cmd.String("project") // ReportPortal project name + analyticsAPISecret := analytics.GetAnalyticArg() // Analytics API secret + analyticsOff := cmd.Bool("analytics-off") // Disable analytics flag hostUrl, err := url.Parse(host) if err != nil { @@ -267,8 +210,8 @@ func newMCPServer(cmd *cli.Command) (*server.MCPServer, *mcpreportportal.Analyti } // Create a new stdio server using the ReportPortal client - mcpServer, analytics, err := mcpreportportal.NewServer( - version, + mcpServer, analyticsClient, err := mcp_handlers.NewServer( + appVersion.Version, hostUrl, token, userID, @@ -279,11 +222,33 @@ func newMCPServer(cmd *cli.Command) (*server.MCPServer, *mcpreportportal.Analyti if err != nil { return nil, nil, fmt.Errorf("failed to create ReportPortal MCP server: %w", err) } - return mcpServer, analytics, nil + return mcpServer, analyticsClient, nil +} + +// HandleServerError processes server errors, distinguishing between graceful shutdowns and actual errors. +// Returns nil for graceful shutdowns, or the original error for actual problems. +func HandleServerError( + err error, + analyticsClient *analytics.Analytics, + serverType string, +) error { + // Check for successful completion or expected shutdown errors + if err == nil || + errors.Is(err, http.ErrServerClosed) || + errors.Is(err, context.Canceled) || + errors.Is(err, context.DeadlineExceeded) { + slog.Info("server shutdown completed", "type", serverType) + analytics.StopAnalytics(analyticsClient, "") + return nil + } + + slog.Error("server error occurred", "type", serverType, "error", err) + analytics.StopAnalytics(analyticsClient, "server error") + return fmt.Errorf("error running %s server: %w", serverType, err) } -// runStdioServer starts the ReportPortal MCP server in stdio mode. -func runStdioServer(ctx context.Context, cmd *cli.Command) error { +// RunStdioServer starts the ReportPortal MCP server in stdio mode. +func RunStdioServer(ctx context.Context, cmd *cli.Command, appVersion AppVersion) error { // Validate that token is provided for stdio mode (required) token := cmd.String("token") if token == "" { @@ -295,9 +260,9 @@ func runStdioServer(ctx context.Context, cmd *cli.Command) error { rpProject := cmd.String("project") if rpProject != "" { // Add project to request context default project name from Environment variable - ctx = mcpreportportal.WithProjectInContext(ctx, rpProject) + ctx = utils.WithProjectInContext(ctx, rpProject) } - mcpServer, analytics, err := newMCPServer(cmd) + mcpServer, analyticsClient, err := NewMCPServer(cmd, appVersion) if err != nil { return fmt.Errorf("failed to create ReportPortal MCP server: %w", err) } @@ -317,23 +282,23 @@ func runStdioServer(ctx context.Context, cmd *cli.Command) error { select { case <-ctx.Done(): // Context canceled (e.g., SIGTERM received) slog.Info("shutting down server...") - mcpreportportal.StopAnalytics(analytics, "") + analytics.StopAnalytics(analyticsClient, "") case err := <-errC: // Error occurred while running the server - return handleServerError(err, analytics, "stdio") + return HandleServerError(err, analyticsClient, "stdio") } return nil } -// runStreamingServer starts the ReportPortal MCP server in streaming mode with HTTP token extraction. -func runStreamingServer(ctx context.Context, cmd *cli.Command) error { +// RunStreamingServer starts the ReportPortal MCP server in streaming mode over HTTP. +func RunStreamingServer(ctx context.Context, cmd *cli.Command, appVersion AppVersion) error { // Build HTTP server configuration from CLI flags with performance tuning - config, err := buildHTTPServerConfig(cmd) + httpConfig, err := BuildHTTPServerConfig(cmd, appVersion) if err != nil { return fmt.Errorf("failed to build HTTP server config: %w", err) } - httpServer, analytics, err := mcpreportportal.CreateHTTPServerWithMiddleware(config) + httpServer, analyticsClient, err := httpserver.CreateHTTPServerWithMiddleware(httpConfig) if err != nil { return fmt.Errorf("failed to create HTTP MCP server: %w", err) } @@ -371,7 +336,7 @@ func runStreamingServer(ctx context.Context, cmd *cli.Command) error { select { case <-ctx.Done(): // Context canceled (e.g., SIGTERM received) slog.Info("shutting down server...") - mcpreportportal.StopAnalytics(analytics, "") + analytics.StopAnalytics(analyticsClient, "") sCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := server.Shutdown(sCtx); err != nil { @@ -381,8 +346,78 @@ func runStreamingServer(ctx context.Context, cmd *cli.Command) error { slog.Error("error stopping HTTP server", "error", err) } case err := <-errC: // Error occurred while running the server - return handleServerError(err, analytics, "http") + return HandleServerError(err, analyticsClient, "http") } return nil } + +// RunApp creates and runs the CLI application with proper configuration +func RunApp(ctx context.Context, appVersion AppVersion) error { + mcpMode := GetMCPMode() + // Build flags based on MCP mode + var allFlags []cli.Flag + allFlags = append(allFlags, GetCommonFlags()...) + if mcpMode == "http" { + allFlags = append(allFlags, GetHTTPFlags()...) + } else { + // stdio mode (default) - add stdio-specific flags + allFlags = append(allFlags, GetStdioFlags()...) + } + + // Define the CLI command structure + cmd := &cli.Command{ + Version: fmt.Sprintf("%s (%s) %s", appVersion.Version, appVersion.Commit, appVersion.Date), + Description: `ReportPortal MCP Server + +ENVIRONMENT VARIABLES: + MCP_MODE Server mode: "stdio" (default) or "http" + Controls which server type to run and which flags are available + +FLAG CATEGORIES: + [GLOBAL/REQUIRED] - Required for all modes (stdio and http) + [GLOBAL/OPTIONAL] - Optional for all modes (stdio and http) + [STDIO-ONLY] - Only available when MCP_MODE=stdio (default) + [HTTP-ONLY] - Only available when MCP_MODE=http + +AUTHENTICATION: + stdio mode: RP_API_TOKEN is REQUIRED (must be set via environment variable or --token flag) + http mode: RP_API_TOKEN and --token are COMPLETELY IGNORED + Tokens MUST be passed per-request via 'Authorization: Bearer ' header + +ANALYTICS: + stdio mode: RP_API_TOKEN is required for analytics (used for secure user identification) + http mode: Analytics uses RP_USER_ID env var for identification + Use --analytics-off or RP_MCP_ANALYTICS_OFF=true to disable analytics + +USAGE EXAMPLES: + # Run in stdio mode (default) + reportportal-mcp-server --rp-host https://reportportal.example.com --token YOUR_TOKEN + + # Run in http mode with custom port + MCP_MODE=http reportportal-mcp-server --rp-host https://reportportal.example.com --port 9090`, + Flags: allFlags, + Before: InitLogger(), + Action: func(ctx context.Context, cmd *cli.Command) error { + // Check mcpMode and run appropriate server + switch mcpMode { + case "http": + return RunStreamingServer(ctx, cmd, appVersion) + case "stdio": + return RunStdioServer(ctx, cmd, appVersion) + default: + slog.Info( + "unknown MCP_MODE, defaulting to stdio", + "mode", + mcpMode, + "supported", + "stdio, http", + ) + return RunStdioServer(ctx, cmd, appVersion) + } + }, + } + + // Run the CLI command and handle any errors + return cmd.Run(ctx, os.Args) +} diff --git a/internal/reportportal/http_server.go b/internal/http/http_server.go similarity index 88% rename from internal/reportportal/http_server.go rename to internal/http/http_server.go index c136f52..6287ea7 100644 --- a/internal/reportportal/http_server.go +++ b/internal/http/http_server.go @@ -1,4 +1,4 @@ -package mcpreportportal +package http import ( "bytes" @@ -14,9 +14,18 @@ import ( "time" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" + chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/mark3labs/mcp-go/server" "github.com/reportportal/goRP/v5/pkg/gorp" + + "github.com/reportportal/reportportal-mcp-server/internal/analytics" + "github.com/reportportal/reportportal-mcp-server/internal/mcp_handlers" + "github.com/reportportal/reportportal-mcp-server/internal/middleware" +) + +const ( + // Batch send interval for analytics data + batchSendInterval = 10 * time.Second ) // createHTTPClient creates a reusable HTTP client with optimal settings @@ -53,7 +62,7 @@ type HTTPServerConfig struct { // HTTPServer is an enhanced MCP server with Chi router type HTTPServer struct { mcpServer *server.MCPServer - analytics *Analytics + analytics *analytics.Analytics config HTTPServerConfig Router chi.Router // Made public for CreateHTTPServerWithMiddleware streamableServer *server.StreamableHTTPServer @@ -97,10 +106,10 @@ func NewHTTPServer(config HTTPServerConfig) (*HTTPServer, error) { // Initialize batch-based analytics // Note: In HTTP mode, FallbackRPToken is always empty (tokens come from HTTP headers). // Analytics uses UserID for identification in HTTP mode. - var analytics *Analytics + var analyticsClient *analytics.Analytics if config.AnalyticsOn && config.GA4Secret != "" { var err error - analytics, err = NewAnalytics( + analyticsClient, err = analytics.NewAnalytics( config.UserID, config.GA4Secret, "", // FallbackRPToken is always empty in HTTP mode @@ -116,7 +125,7 @@ func NewHTTPServer(config HTTPServerConfig) (*HTTPServer, error) { httpServer := &HTTPServer{ mcpServer: mcpServer, - analytics: analytics, + analytics: analyticsClient, config: config, httpClient: httpClient, } @@ -140,44 +149,15 @@ func (hs *HTTPServer) initializeTools() error { // Use HTTP client rpClient.APIClient.GetConfig().HTTPClient = hs.httpClient - rpClient.APIClient.GetConfig().Middleware = QueryParamsMiddleware - - // Add launch management tools with analytics - launches := NewLaunchResources(rpClient, hs.analytics, "") - - hs.mcpServer.AddTool(launches.toolGetLaunches()) - hs.mcpServer.AddTool(launches.toolGetLastLaunchByName()) - hs.mcpServer.AddTool(launches.toolForceFinishLaunch()) - hs.mcpServer.AddTool(launches.toolDeleteLaunch()) - hs.mcpServer.AddTool(launches.toolRunAutoAnalysis()) - hs.mcpServer.AddTool(launches.toolUniqueErrorAnalysis()) - hs.mcpServer.AddTool(launches.toolRunQualityGate()) - - hs.mcpServer.AddResourceTemplate(launches.resourceLaunch()) + rpClient.APIClient.GetConfig().Middleware = middleware.QueryParamsMiddleware - // Add test item tools - testItems := NewTestItemResources(rpClient, hs.analytics, "") + // Register launch management tools with analytics + mcp_handlers.RegisterLaunchTools(hs.mcpServer, rpClient, hs.analytics, "") - hs.mcpServer.AddTool(testItems.toolGetTestItemById()) - hs.mcpServer.AddTool(testItems.toolGetTestItemsByFilter()) - hs.mcpServer.AddTool(testItems.toolGetTestItemLogsByFilter()) - hs.mcpServer.AddTool(testItems.toolGetTestItemAttachment()) - hs.mcpServer.AddTool(testItems.toolGetTestSuitesByFilter()) - hs.mcpServer.AddTool(testItems.toolGetProjectDefectTypes()) - hs.mcpServer.AddTool(testItems.toolUpdateDefectTypeForTestItems()) - - hs.mcpServer.AddResourceTemplate(testItems.resourceTestItem()) - - // Add prompts - prompts, err := readPrompts(promptFiles, "prompts") - if err != nil { - return fmt.Errorf("failed to load prompts: %w", err) - } - - for _, prompt := range prompts { - hs.mcpServer.AddPrompt(prompt.Prompt, prompt.Handler) - } + // Register test item tools + mcp_handlers.RegisterTestItemTools(hs.mcpServer, rpClient, hs.analytics, "") + // Prompts are registered by mcp_handlers package return nil } @@ -228,7 +208,7 @@ func (hs *HTTPServer) Stop() error { // CreateHTTPServerWithMiddleware creates a complete HTTP server setup with middleware func CreateHTTPServerWithMiddleware( config HTTPServerConfig, -) (*HTTPServerWithMiddleware, *Analytics, error) { +) (*HTTPServerWithMiddleware, *analytics.Analytics, error) { // Create the MCP server with Chi router and middleware already configured mcpServer, err := NewHTTPServer(config) if err != nil { @@ -299,7 +279,7 @@ func (hs *HTTPServer) conditionalTimeoutMiddleware(next http.Handler) http.Handl return } // Apply timeout for regular requests - middleware.Timeout(hs.config.ConnectionTimeout)(next).ServeHTTP(w, r) + chimiddleware.Timeout(hs.config.ConnectionTimeout)(next).ServeHTTP(w, r) }) } @@ -311,15 +291,15 @@ func (hs *HTTPServer) setupChiRouter() { r.Use(corsMiddleware) // Add Chi middleware - r.Use(middleware.RequestID) - r.Use(middleware.RealIP) - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) + r.Use(chimiddleware.RequestID) + r.Use(chimiddleware.RealIP) + r.Use(chimiddleware.Logger) + r.Use(chimiddleware.Recoverer) // Use conditional timeout that skips SSE streams r.Use(hs.conditionalTimeoutMiddleware) // Add HTTP concurrency control - r.Use(middleware.Throttle(hs.config.MaxConcurrentRequests)) + r.Use(chimiddleware.Throttle(hs.config.MaxConcurrentRequests)) // Create streamable server for MCP functionality hs.streamableServer = server.NewStreamableHTTPServer(hs.mcpServer) @@ -359,7 +339,7 @@ func (hs *HTTPServer) setupRoutes() { // MCP endpoints using chi.Group pattern hs.Router.Group(func(mcpRouter chi.Router) { // Add MCP-specific middleware for token extraction and validation - mcpRouter.Use(HTTPTokenMiddleware) + mcpRouter.Use(middleware.HTTPTokenMiddleware) mcpRouter.Use(hs.mcpMiddleware) // Handle all MCP endpoints @@ -371,12 +351,12 @@ func (hs *HTTPServer) setupRoutes() { } // GetHTTPServerInfo returns information about the HTTP server configuration -func GetHTTPServerInfo(analytics *Analytics) HTTPServerInfo { +func GetHTTPServerInfo(analyticsClient *analytics.Analytics) HTTPServerInfo { info := HTTPServerInfo{ Type: "http_mcp_server", } - if analytics != nil { + if analyticsClient != nil { info.Analytics = AnalyticsInfo{ Enabled: true, Type: "batch", diff --git a/internal/reportportal/http_server_test.go b/internal/http/http_server_test.go similarity index 96% rename from internal/reportportal/http_server_test.go rename to internal/http/http_server_test.go index c8004b1..5dc66d3 100644 --- a/internal/reportportal/http_server_test.go +++ b/internal/http/http_server_test.go @@ -1,4 +1,4 @@ -package mcpreportportal +package http import ( "net/url" @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/reportportal/reportportal-mcp-server/internal/analytics" ) func TestNewHTTPServer_WithoutRPAPIToken(t *testing.T) { @@ -333,7 +335,7 @@ func TestHTTPServer_StopIdempotent(t *testing.T) { func TestGetHTTPServerInfo(t *testing.T) { tests := []struct { name string - analytics *Analytics + analytics *analytics.Analytics expectAnalytics bool expectedType string expectedInterval string @@ -345,11 +347,14 @@ func TestGetHTTPServerInfo(t *testing.T) { }, { name: "server info with analytics", - analytics: &Analytics{ - config: &AnalyticsConfig{ - APISecret: "test-secret", - }, - }, + analytics: func() *analytics.Analytics { + // Create a test analytics instance using the factory function + analyticsInstance, err := analytics.NewAnalytics("test-user", "test-secret", "") + if err != nil { + panic(err) + } + return analyticsInstance + }(), expectAnalytics: true, expectedType: "batch", expectedInterval: batchSendInterval.String(), diff --git a/internal/reportportal/integration_project_test.go b/internal/mcp_handlers/integration_project_test.go similarity index 83% rename from internal/reportportal/integration_project_test.go rename to internal/mcp_handlers/integration_project_test.go index 85ff57f..3760df8 100644 --- a/internal/reportportal/integration_project_test.go +++ b/internal/mcp_handlers/integration_project_test.go @@ -1,4 +1,4 @@ -package mcpreportportal +package mcp_handlers import ( "context" @@ -6,7 +6,11 @@ import ( "net/http/httptest" "testing" + "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" + + "github.com/reportportal/reportportal-mcp-server/internal/middleware" + "github.com/reportportal/reportportal-mcp-server/internal/utils" ) func TestIntegration_ProjectExtractionFlow(t *testing.T) { @@ -83,14 +87,15 @@ func TestIntegration_ProjectExtractionFlow(t *testing.T) { req.Header.Set(key, value) } - // Create mock MCP request - mcpRequest := MockCallToolRequest{ - project: tt.requestProject, + // Create MCP request + var mcpRequest mcp.CallToolRequest + mcpRequest.Params.Arguments = map[string]any{ + "project": tt.requestProject, } // Apply middleware to get context with project var ctx context.Context - middleware := HTTPTokenMiddleware( + httpHandler := middleware.HTTPTokenMiddleware( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx = r.Context() w.WriteHeader(http.StatusOK) @@ -98,10 +103,10 @@ func TestIntegration_ProjectExtractionFlow(t *testing.T) { ) rr := httptest.NewRecorder() - middleware.ServeHTTP(rr, req) + httpHandler.ServeHTTP(rr, req) // Test extractProject with the context from middleware - result, err := extractProjectWithMock(ctx, mcpRequest) + result, err := utils.ExtractProject(ctx, mcpRequest) if tt.expectError { assert.Error(t, err) @@ -126,12 +131,13 @@ func TestIntegration_CompleteHTTPFlow(t *testing.T) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Simulate MCP tool request with explicit project parameter // This should take precedence over the HTTP header - mcpRequest := MockCallToolRequest{ - project: "request-project", + var mcpRequest mcp.CallToolRequest + mcpRequest.Params.Arguments = map[string]any{ + "project": "request-project", } // Extract project using our function - project, err := extractProjectWithMock(r.Context(), mcpRequest) + project, err := utils.ExtractProject(r.Context(), mcpRequest) if err != nil { w.WriteHeader(http.StatusInternalServerError) return @@ -143,11 +149,11 @@ func TestIntegration_CompleteHTTPFlow(t *testing.T) { }) // Apply middleware - middleware := HTTPTokenMiddleware(handler) + httpHandler := middleware.HTTPTokenMiddleware(handler) rr := httptest.NewRecorder() // Execute request - middleware.ServeHTTP(rr, req) + httpHandler.ServeHTTP(rr, req) // Verify results - request parameter should win assert.Equal(t, http.StatusOK, rr.Code) diff --git a/internal/reportportal/items.go b/internal/mcp_handlers/items.go similarity index 86% rename from internal/reportportal/items.go rename to internal/mcp_handlers/items.go index e1b601b..8aaa6a6 100644 --- a/internal/reportportal/items.go +++ b/internal/mcp_handlers/items.go @@ -1,4 +1,4 @@ -package mcpreportportal +package mcp_handlers import ( "context" @@ -14,18 +14,21 @@ import ( "github.com/reportportal/goRP/v5/pkg/gorp" "github.com/reportportal/goRP/v5/pkg/openapi" "github.com/yosida95/uritemplate/v3" + + "github.com/reportportal/reportportal-mcp-server/internal/analytics" + "github.com/reportportal/reportportal-mcp-server/internal/utils" ) // TestItemResources is a struct that encapsulates the ReportPortal client. type TestItemResources struct { client *gorp.Client // Client to interact with the ReportPortal API projectParameter mcp.ToolOption - analytics *Analytics + analytics *analytics.Analytics } func NewTestItemResources( client *gorp.Client, - analytics *Analytics, + analyticsClient *analytics.Analytics, project string, ) *TestItemResources { return &TestItemResources{ @@ -34,7 +37,7 @@ func NewTestItemResources( mcp.Description("Project name"), mcp.DefaultString(project), ), - analytics: analytics, + analytics: analyticsClient, } } @@ -52,7 +55,7 @@ func (lr *TestItemResources) toolGetTestItemsByFilter() (tool mcp.Tool, handler } // Add pagination parameters - options = append(options, setPaginationOptions(defaultSortingForItems)...) + options = append(options, utils.SetPaginationOptions(utils.DefaultSortingForItems)...) // Add other parameters options = append(options, []mcp.ToolOption{ @@ -123,7 +126,7 @@ func (lr *TestItemResources) toolGetTestItemsByFilter() (tool mcp.Tool, handler "get_test_items_by_filter", options...), lr.analytics.WithAnalytics("get_test_items_by_filter", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { slog.Debug("START PROCESSING") - project, err := extractProject(ctx, request) + project, err := utils.ExtractProject(ctx, request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -150,10 +153,10 @@ func (lr *TestItemResources) toolGetTestItemsByFilter() (tool mcp.Tool, handler filterPatternName := request.GetString("filter-any-patternName", "") urlValues := url.Values{ - "providerType": {defaultProviderType}, - "filter.eq.hasStats": {defaultFilterEqHasStats}, - "filter.eq.hasChildren": {defaultFilterEqHasChildren}, - "filter.in.type": {defaultFilterInType}, + "providerType": {utils.DefaultProviderType}, + "filter.eq.hasStats": {utils.DefaultFilterEqHasStats}, + "filter.eq.hasChildren": {utils.DefaultFilterEqHasChildren}, + "filter.in.type": {utils.DefaultFilterInType}, } urlValues.Add("launchId", strconv.Itoa(launchId)) @@ -186,7 +189,10 @@ func (lr *TestItemResources) toolGetTestItemsByFilter() (tool mcp.Tool, handler urlValues.Add("filter.any.patternName", filterPatternName) } - filterStartTime, err := processStartTimeFilter(filterStartTimeFrom, filterStartTimeTo) + filterStartTime, err := utils.ProcessStartTimeFilter( + filterStartTimeFrom, + filterStartTimeTo, + ) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -197,7 +203,7 @@ func (lr *TestItemResources) toolGetTestItemsByFilter() (tool mcp.Tool, handler urlValues.Add("filter.in.ignoreAnalyzer", strconv.FormatBool(filterIgnoreAnalyzer)) } - ctxWithParams := WithQueryParams(ctx, urlValues) + ctxWithParams := utils.WithQueryParams(ctx, urlValues) // Prepare "requiredUrlParams" for the API request because the ReportPortal API v2 expects them in a specific format requiredUrlParams := map[string]string{ "launchId": strconv.Itoa(launchId), @@ -207,10 +213,14 @@ func (lr *TestItemResources) toolGetTestItemsByFilter() (tool mcp.Tool, handler Params(requiredUrlParams) // Apply pagination parameters - apiRequest = applyPaginationOptions(apiRequest, request, defaultSortingForItems) + apiRequest = utils.ApplyPaginationOptions( + apiRequest, + request, + utils.DefaultSortingForItems, + ) // Process attribute keys and combine with composite attributes - filterAttributes = processAttributeKeys(filterAttributes, filterAttributeKeys) + filterAttributes = utils.ProcessAttributeKeys(filterAttributes, filterAttributeKeys) if filterAttributes != "" { apiRequest = apiRequest.FilterHasCompositeAttribute(filterAttributes) } @@ -224,11 +234,11 @@ func (lr *TestItemResources) toolGetTestItemsByFilter() (tool mcp.Tool, handler // Execute the request _, response, err := apiRequest.Execute() if err != nil { - return mcp.NewToolResultError(extractResponseError(err, response)), nil + return mcp.NewToolResultError(utils.ExtractResponseError(err, response)), nil } // Return the serialized launches as a text result - return readResponseBody(response) + return utils.ReadResponseBody(response) }) } @@ -242,7 +252,7 @@ func (lr *TestItemResources) toolGetTestItemById() (mcp.Tool, server.ToolHandler mcp.Description("Test Item ID"), ), ), lr.analytics.WithAnalytics("get_test_item_by_id", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - project, err := extractProject(ctx, request) + project, err := utils.ExtractProject(ctx, request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -256,11 +266,11 @@ func (lr *TestItemResources) toolGetTestItemById() (mcp.Tool, server.ToolHandler _, response, err := lr.client.TestItemAPI.GetTestItem(ctx, testItemID, project). Execute() if err != nil { - return mcp.NewToolResultError(extractResponseError(err, response)), nil + return mcp.NewToolResultError(utils.ExtractResponseError(err, response)), nil } // Return the serialized testItem as a text result - return readResponseBody(response) + return utils.ReadResponseBody(response) }) } @@ -313,7 +323,7 @@ func (lr *TestItemResources) toolGetTestItemAttachment() (mcp.Tool, server.ToolH mcp.Description("Attachment binary content ID"), ), ), lr.analytics.WithAnalytics("get_test_item_attachment_by_id", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - project, err := extractProject(ctx, request) + project, err := utils.ExtractProject(ctx, request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -334,19 +344,19 @@ func (lr *TestItemResources) toolGetTestItemAttachment() (mcp.Tool, server.ToolH response, err := lr.client.FileStorageAPI.GetFile(ctx, attachmentId, project). Execute() if err != nil { - return mcp.NewToolResultError(extractResponseError(err, response)), nil + return mcp.NewToolResultError(utils.ExtractResponseError(err, response)), nil } // Handle response body with cleanup - rawBody, err := readResponseBodyRaw(response) + rawBody, err := utils.ReadResponseBodyRaw(response) if err != nil { - return mcp.NewToolResultError(extractResponseError(err, response)), nil + return mcp.NewToolResultError(utils.ExtractResponseError(err, response)), nil } contentType := response.Header.Get("Content-Type") // Return appropriate MCP result type based on content type - if isTextContent(contentType) { + if utils.IsTextContent(contentType) { return mcp.NewToolResultResource( fmt.Sprintf("Text content (%s, %d bytes)", contentType, len(rawBody)), mcp.TextResourceContents{ @@ -380,20 +390,20 @@ func (lr *TestItemResources) toolGetTestItemLogsByFilter() (tool mcp.Tool, handl mcp.Description("Items with specific Parent Item ID, this is a required parameter"), ), mcp.WithNumber("page", // Parameter for specifying the page number - mcp.DefaultNumber(firstPage), + mcp.DefaultNumber(utils.FirstPage), mcp.Description("Page number"), ), mcp.WithNumber("page-size", // Parameter for specifying the page size - mcp.DefaultNumber(defaultPageSize), + mcp.DefaultNumber(utils.DefaultPageSize), mcp.Description("Page size"), ), mcp.WithString("page-sort", // Sorting fields and direction - mcp.DefaultString(defaultSortingForLogs), + mcp.DefaultString(utils.DefaultSortingForLogs), mcp.Description("Sorting fields and direction"), ), // Optional filters mcp.WithString("filter-gte-level", // Item's log level - mcp.DefaultString(defaultItemLogLevel), + mcp.DefaultString(utils.DefaultItemLogLevel), mcp.Description("Get logs only with specific log level"), ), mcp.WithString( @@ -418,7 +428,7 @@ func (lr *TestItemResources) toolGetTestItemLogsByFilter() (tool mcp.Tool, handl ), ), lr.analytics.WithAnalytics("get_test_item_logs_by_filter", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { slog.Debug("START PROCESSING") - project, err := extractProject(ctx, request) + project, err := utils.ExtractProject(ctx, request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -464,7 +474,7 @@ func (lr *TestItemResources) toolGetTestItemLogsByFilter() (tool mcp.Tool, handl } } - ctxWithParams := WithQueryParams(ctx, urlValues) + ctxWithParams := utils.WithQueryParams(ctx, urlValues) // Prepare "requiredUrlParams" for the API request because the ReportPortal API expects them in a specific format requiredUrlParams := map[string]string{ "parentId": parentIdStr, @@ -474,15 +484,19 @@ func (lr *TestItemResources) toolGetTestItemLogsByFilter() (tool mcp.Tool, handl Params(requiredUrlParams) // Apply pagination parameters - apiRequest = applyPaginationOptions(apiRequest, request, defaultSortingForLogs) + apiRequest = utils.ApplyPaginationOptions( + apiRequest, + request, + utils.DefaultSortingForLogs, + ) // Execute the request _, response, err := apiRequest.Execute() if err != nil { - return mcp.NewToolResultError(extractResponseError(err, response)), nil + return mcp.NewToolResultError(utils.ExtractResponseError(err, response)), nil } - return readResponseBody(response) + return utils.ReadResponseBody(response) }) } @@ -500,7 +514,7 @@ func (lr *TestItemResources) toolGetTestSuitesByFilter() (tool mcp.Tool, handler } // Add pagination parameters - options = append(options, setPaginationOptions(defaultSortingForSuites)...) + options = append(options, utils.SetPaginationOptions(utils.DefaultSortingForSuites)...) // Add other parameters options = append(options, []mcp.ToolOption{ @@ -544,7 +558,7 @@ func (lr *TestItemResources) toolGetTestSuitesByFilter() (tool mcp.Tool, handler "get_test_suites_by_filter", options...), lr.analytics.WithAnalytics("get_test_suites_by_filter", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { slog.Debug("START PROCESSING") - project, err := extractProject(ctx, request) + project, err := utils.ExtractProject(ctx, request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -564,8 +578,8 @@ func (lr *TestItemResources) toolGetTestSuitesByFilter() (tool mcp.Tool, handler filterParentId := request.GetString("filter.eq.parentId", "") urlValues := url.Values{ - "providerType": {defaultProviderType}, - "filter.in.type": {defaultFilterInTypeSuites}, + "providerType": {utils.DefaultProviderType}, + "filter.in.type": {utils.DefaultFilterInTypeSuites}, } urlValues.Add("launchId", strconv.Itoa(launchId)) @@ -586,7 +600,10 @@ func (lr *TestItemResources) toolGetTestSuitesByFilter() (tool mcp.Tool, handler urlValues.Add("filter.eq.parentId", filterParentId) } - filterStartTime, err := processStartTimeFilter(filterStartTimeFrom, filterStartTimeTo) + filterStartTime, err := utils.ProcessStartTimeFilter( + filterStartTimeFrom, + filterStartTimeTo, + ) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -594,7 +611,7 @@ func (lr *TestItemResources) toolGetTestSuitesByFilter() (tool mcp.Tool, handler urlValues.Add("filter.btw.startTime", filterStartTime) } - ctxWithParams := WithQueryParams(ctx, urlValues) + ctxWithParams := utils.WithQueryParams(ctx, urlValues) // Prepare "requiredUrlParams" for the API request because the ReportPortal API v2 expects them in a specific format requiredUrlParams := map[string]string{ "launchId": strconv.Itoa(launchId), @@ -604,10 +621,14 @@ func (lr *TestItemResources) toolGetTestSuitesByFilter() (tool mcp.Tool, handler Params(requiredUrlParams) // Apply pagination parameters - apiRequest = applyPaginationOptions(apiRequest, request, defaultSortingForSuites) + apiRequest = utils.ApplyPaginationOptions( + apiRequest, + request, + utils.DefaultSortingForSuites, + ) // Process attribute keys and combine with composite attributes - filterAttributes = processAttributeKeys(filterAttributes, filterAttributeKeys) + filterAttributes = utils.ProcessAttributeKeys(filterAttributes, filterAttributeKeys) if filterAttributes != "" { apiRequest = apiRequest.FilterHasCompositeAttribute(filterAttributes) } @@ -615,11 +636,11 @@ func (lr *TestItemResources) toolGetTestSuitesByFilter() (tool mcp.Tool, handler // Execute the request _, response, err := apiRequest.Execute() if err != nil { - return mcp.NewToolResultError(extractResponseError(err, response)), nil + return mcp.NewToolResultError(utils.ExtractResponseError(err, response)), nil } // Return the serialized test suites as a text result - return readResponseBody(response) + return utils.ReadResponseBody(response) }) } @@ -663,7 +684,7 @@ func (lr *TestItemResources) toolGetProjectDefectTypes() (mcp.Tool, server.ToolH ), lr.projectParameter, ), lr.analytics.WithAnalytics("get_project_defect_types", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - project, err := extractProject(ctx, request) + project, err := utils.ExtractProject(ctx, request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -672,11 +693,11 @@ func (lr *TestItemResources) toolGetProjectDefectTypes() (mcp.Tool, server.ToolH _, response, err := lr.client.ProjectAPI.GetProject(ctx, project). Execute() if err != nil { - return mcp.NewToolResultError(extractResponseError(err, response)), nil + return mcp.NewToolResultError(utils.ExtractResponseError(err, response)), nil } // Read and parse the response to extract configuration/subtypes - rawBody, err := readResponseBodyRaw(response) + rawBody, err := utils.ReadResponseBodyRaw(response) if err != nil { return mcp.NewToolResultError( fmt.Sprintf("failed to read response body: %v", err), @@ -724,7 +745,7 @@ func (lr *TestItemResources) toolUpdateDefectTypeForTestItems() (mcp.Tool, serve ), ), ), lr.analytics.WithAnalytics("update_defect_type_for_test_items", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - project, err := extractProject(ctx, request) + project, err := utils.ExtractProject(ctx, request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -770,10 +791,28 @@ func (lr *TestItemResources) toolUpdateDefectTypeForTestItems() (mcp.Tool, serve // Execute the request _, response, err := apiRequest.Execute() if err != nil { - return mcp.NewToolResultError(extractResponseError(err, response)), nil + return mcp.NewToolResultError(utils.ExtractResponseError(err, response)), nil } // Return the serialized testItem as a text result - return readResponseBody(response) + return utils.ReadResponseBody(response) }) } + +// RegisterTestItemTools registers all test item-related tools and resources with the MCP server +func RegisterTestItemTools( + s *server.MCPServer, + client *gorp.Client, + analyticsClient *analytics.Analytics, + project string, +) { + testItems := NewTestItemResources(client, analyticsClient, project) + s.AddTool(testItems.toolGetTestItemById()) + s.AddTool(testItems.toolGetTestItemsByFilter()) + s.AddTool(testItems.toolGetTestItemLogsByFilter()) + s.AddTool(testItems.toolGetTestItemAttachment()) + s.AddTool(testItems.toolGetTestSuitesByFilter()) + s.AddTool(testItems.toolGetProjectDefectTypes()) + s.AddTool(testItems.toolUpdateDefectTypeForTestItems()) + s.AddResourceTemplate(testItems.resourceTestItem()) +} diff --git a/internal/reportportal/items_test.go b/internal/mcp_handlers/items_test.go similarity index 99% rename from internal/reportportal/items_test.go rename to internal/mcp_handlers/items_test.go index 227bc14..d1708b9 100644 --- a/internal/reportportal/items_test.go +++ b/internal/mcp_handlers/items_test.go @@ -1,4 +1,4 @@ -package mcpreportportal +package mcp_handlers import ( "testing" diff --git a/internal/reportportal/launches.go b/internal/mcp_handlers/launches.go similarity index 87% rename from internal/reportportal/launches.go rename to internal/mcp_handlers/launches.go index 5c45301..1db0dd6 100644 --- a/internal/reportportal/launches.go +++ b/internal/mcp_handlers/launches.go @@ -1,4 +1,4 @@ -package mcpreportportal +package mcp_handlers import ( "context" @@ -13,18 +13,21 @@ import ( "github.com/reportportal/goRP/v5/pkg/gorp" "github.com/reportportal/goRP/v5/pkg/openapi" "github.com/yosida95/uritemplate/v3" + + "github.com/reportportal/reportportal-mcp-server/internal/analytics" + "github.com/reportportal/reportportal-mcp-server/internal/utils" ) // LaunchResources is a struct that encapsulates the ReportPortal client. type LaunchResources struct { client *gorp.Client // Client to interact with the ReportPortal API projectParameter mcp.ToolOption - analytics *Analytics + analytics *analytics.Analytics } func NewLaunchResources( client *gorp.Client, - analytics *Analytics, + analyticsClient *analytics.Analytics, project string, ) *LaunchResources { return &LaunchResources{ @@ -33,7 +36,7 @@ func NewLaunchResources( mcp.Description("Project name"), mcp.DefaultString(project), ), - analytics: analytics, + analytics: analyticsClient, } } @@ -46,7 +49,7 @@ func (lr *LaunchResources) toolGetLaunches() (tool mcp.Tool, handler server.Tool } // Add pagination parameters - options = append(options, setPaginationOptions(defaultSortingForLaunches)...) + options = append(options, utils.SetPaginationOptions(utils.DefaultSortingForLaunches)...) // Add other parameters options = append(options, []mcp.ToolOption{ @@ -94,7 +97,7 @@ func (lr *LaunchResources) toolGetLaunches() (tool mcp.Tool, handler server.Tool return mcp.NewTool( "get_launches", options...), lr.analytics.WithAnalytics("get_launches", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - project, err := extractProject(ctx, request) + project, err := utils.ExtractProject(ctx, request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -118,7 +121,10 @@ func (lr *LaunchResources) toolGetLaunches() (tool mcp.Tool, handler server.Tool if filterDescription != "" { urlValues.Add("filter.cnt.description", filterDescription) } - filterStartTime, err := processStartTimeFilter(filterStartTimeFrom, filterStartTimeTo) + filterStartTime, err := utils.ProcessStartTimeFilter( + filterStartTimeFrom, + filterStartTimeTo, + ) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -132,25 +138,29 @@ func (lr *LaunchResources) toolGetLaunches() (tool mcp.Tool, handler server.Tool urlValues.Add("filter.gte.number", strconv.Itoa(filterGreaterThanNumber)) } - ctxWithParams := WithQueryParams(ctx, urlValues) + ctxWithParams := utils.WithQueryParams(ctx, urlValues) // Build API request and apply pagination directly apiRequest := lr.client.LaunchAPI.GetProjectLaunches(ctxWithParams, project) // Apply pagination parameters - apiRequest = applyPaginationOptions(apiRequest, request, defaultSortingForLaunches) + apiRequest = utils.ApplyPaginationOptions( + apiRequest, + request, + utils.DefaultSortingForLaunches, + ) // Process attribute keys and combine with composite attributes - filterAttributes = processAttributeKeys(filterAttributes, filterAttributeKeys) + filterAttributes = utils.ProcessAttributeKeys(filterAttributes, filterAttributeKeys) if filterAttributes != "" { apiRequest = apiRequest.FilterHasCompositeAttribute(filterAttributes) } _, response, err := apiRequest.Execute() if err != nil { - return mcp.NewToolResultError(extractResponseError(err, response)), nil + return mcp.NewToolResultError(utils.ExtractResponseError(err, response)), nil } - return readResponseBody(response) + return utils.ReadResponseBody(response) }) } @@ -163,7 +173,7 @@ func (lr *LaunchResources) toolRunQualityGate() (tool mcp.Tool, handler server.T mcp.Description("Launch ID"), ), ), lr.analytics.WithAnalytics("run_quality_gate", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - project, err := extractProject(ctx, request) + project, err := utils.ExtractProject(ctx, request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -179,11 +189,11 @@ func (lr *LaunchResources) toolRunQualityGate() (tool mcp.Tool, handler server.T }). Execute() if err != nil { - return mcp.NewToolResultError(extractResponseError(err, response)), nil + return mcp.NewToolResultError(utils.ExtractResponseError(err, response)), nil } // Handle response body and return it as a text result - return readResponseBody(response) + return utils.ReadResponseBody(response) }) } @@ -197,7 +207,7 @@ func (lr *LaunchResources) toolGetLastLaunchByName() (mcp.Tool, server.ToolHandl mcp.Description("Launch name"), ), ), lr.analytics.WithAnalytics("get_last_launch_by_name", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - project, err := extractProject(ctx, request) + project, err := utils.ExtractProject(ctx, request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -213,13 +223,17 @@ func (lr *LaunchResources) toolGetLastLaunchByName() (mcp.Tool, server.ToolHandl urlValues := url.Values{ "filter.cnt.name": {launchName}, } - ctxWithParams := WithQueryParams(ctx, urlValues) + ctxWithParams := utils.WithQueryParams(ctx, urlValues) // Fetch the launches matching the provided name apiRequest := lr.client.LaunchAPI.GetProjectLaunches(ctxWithParams, project) // Apply pagination parameters - apiRequest = applyPaginationOptions(apiRequest, request, defaultSortingForLaunches) + apiRequest = utils.ApplyPaginationOptions( + apiRequest, + request, + utils.DefaultSortingForLaunches, + ) launches, _, err := apiRequest.Execute() if err != nil { @@ -251,7 +265,7 @@ func (lr *LaunchResources) toolDeleteLaunch() (mcp.Tool, server.ToolHandlerFunc) mcp.Description("Launch ID"), ), ), lr.analytics.WithAnalytics("launch_delete", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - project, err := extractProject(ctx, request) + project, err := utils.ExtractProject(ctx, request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -310,7 +324,7 @@ func (lr *LaunchResources) toolRunAutoAnalysis() (mcp.Tool, server.ToolHandlerFu mcp.Required(), ), ), lr.analytics.WithAnalytics("run_auto_analysis", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - project, err := extractProject(ctx, request) + project, err := utils.ExtractProject(ctx, request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -368,7 +382,7 @@ func (lr *LaunchResources) toolUniqueErrorAnalysis() (mcp.Tool, server.ToolHandl mcp.DefaultBool(false), ), ), lr.analytics.WithAnalytics("run_unique_error_analysis", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - project, err := extractProject(ctx, request) + project, err := utils.ExtractProject(ctx, request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -407,7 +421,7 @@ func (lr *LaunchResources) toolForceFinishLaunch() (mcp.Tool, server.ToolHandler mcp.Description("Launch ID"), ), ), lr.analytics.WithAnalytics("launch_force_finish", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - project, err := extractProject(ctx, request) + project, err := utils.ExtractProject(ctx, request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -479,3 +493,21 @@ func (lr *LaunchResources) resourceLaunch() (mcp.ResourceTemplate, server.Resour }, nil } } + +// RegisterLaunchTools registers all launch-related tools and resources with the MCP server +func RegisterLaunchTools( + s *server.MCPServer, + client *gorp.Client, + analyticsClient *analytics.Analytics, + project string, +) { + launches := NewLaunchResources(client, analyticsClient, project) + s.AddTool(launches.toolGetLaunches()) + s.AddTool(launches.toolGetLastLaunchByName()) + s.AddTool(launches.toolForceFinishLaunch()) + s.AddTool(launches.toolDeleteLaunch()) + s.AddTool(launches.toolRunAutoAnalysis()) + s.AddTool(launches.toolUniqueErrorAnalysis()) + s.AddTool(launches.toolRunQualityGate()) + s.AddResourceTemplate(launches.resourceLaunch()) +} diff --git a/internal/reportportal/launches_test.go b/internal/mcp_handlers/launches_test.go similarity index 99% rename from internal/reportportal/launches_test.go rename to internal/mcp_handlers/launches_test.go index 123813c..ed2c664 100644 --- a/internal/reportportal/launches_test.go +++ b/internal/mcp_handlers/launches_test.go @@ -1,4 +1,4 @@ -package mcpreportportal +package mcp_handlers import ( "context" diff --git a/internal/reportportal/prompts/launch.yaml b/internal/mcp_handlers/prompts/launch.yaml similarity index 100% rename from internal/reportportal/prompts/launch.yaml rename to internal/mcp_handlers/prompts/launch.yaml diff --git a/internal/reportportal/server.go b/internal/mcp_handlers/server.go similarity index 61% rename from internal/reportportal/server.go rename to internal/mcp_handlers/server.go index 1674f30..b413372 100644 --- a/internal/reportportal/server.go +++ b/internal/mcp_handlers/server.go @@ -1,15 +1,18 @@ -package mcpreportportal +package mcp_handlers import ( "embed" "fmt" "io/fs" + "log/slog" "net/url" "path/filepath" "github.com/mark3labs/mcp-go/server" "github.com/reportportal/goRP/v5/pkg/gorp" + "github.com/reportportal/reportportal-mcp-server/internal/analytics" + "github.com/reportportal/reportportal-mcp-server/internal/middleware" "github.com/reportportal/reportportal-mcp-server/internal/promptreader" ) @@ -22,7 +25,7 @@ func NewServer( token, userID, project, analyticsAPISecret string, analyticsOn bool, -) (*server.MCPServer, *Analytics, error) { +) (*server.MCPServer, *analytics.Analytics, error) { s := server.NewMCPServer( "reportportal-mcp-server", version, @@ -34,39 +37,32 @@ func NewServer( // Create a new ReportPortal client rpClient := gorp.NewClient(hostUrl, token) - rpClient.APIClient.GetConfig().Middleware = QueryParamsMiddleware + rpClient.APIClient.GetConfig().Middleware = middleware.QueryParamsMiddleware // Initialize analytics (disabled if analyticsOff is true) - var analytics *Analytics - if analyticsOn { + // Note: Analytics initialization uses "best-effort" approach - failures are logged + // but don't prevent server startup, consistent with HTTP server behavior + var analyticsClient *analytics.Analytics + if analyticsOn && analyticsAPISecret != "" { var err error // Pass RP API token for secure hashing as user identifier - analytics, err = NewAnalytics(userID, analyticsAPISecret, token) + analyticsClient, err = analytics.NewAnalytics(userID, analyticsAPISecret, token) if err != nil { - return nil, nil, fmt.Errorf("failed to initialize analytics: %w", err) + slog.Warn("Failed to initialize analytics", "error", err) + } else { + slog.Info("MCP server initialized with batch-based analytics", + "has_ga4_secret", analyticsAPISecret != "", + "uses_user_id", userID != "") } } - launches := NewLaunchResources(rpClient, analytics, project) - s.AddTool(launches.toolGetLaunches()) - s.AddTool(launches.toolGetLastLaunchByName()) - s.AddTool(launches.toolForceFinishLaunch()) - s.AddTool(launches.toolDeleteLaunch()) - s.AddTool(launches.toolRunAutoAnalysis()) - s.AddTool(launches.toolUniqueErrorAnalysis()) - s.AddTool(launches.toolRunQualityGate()) - s.AddResourceTemplate(launches.resourceLaunch()) + // Register launch tools and resources + RegisterLaunchTools(s, rpClient, analyticsClient, project) - testItems := NewTestItemResources(rpClient, analytics, project) - s.AddTool(testItems.toolGetTestItemById()) - s.AddTool(testItems.toolGetTestItemsByFilter()) - s.AddTool(testItems.toolGetTestItemLogsByFilter()) - s.AddTool(testItems.toolGetTestItemAttachment()) - s.AddTool(testItems.toolGetTestSuitesByFilter()) - s.AddTool(testItems.toolGetProjectDefectTypes()) - s.AddTool(testItems.toolUpdateDefectTypeForTestItems()) - s.AddResourceTemplate(testItems.resourceTestItem()) + // Register test item tools and resources + RegisterTestItemTools(s, rpClient, analyticsClient, project) + // Register prompts prompts, err := readPrompts(promptFiles, "prompts") if err != nil { return nil, nil, fmt.Errorf("failed to load prompts: %w", err) @@ -76,7 +72,7 @@ func NewServer( s.AddPrompt(prompt.Prompt, prompt.Handler) } - return s, analytics, nil + return s, analyticsClient, nil } // readPrompts reads multiple YAML files containing prompt definitions diff --git a/internal/reportportal/http_token_middleware.go b/internal/middleware/http_token_middleware.go similarity index 78% rename from internal/reportportal/http_token_middleware.go rename to internal/middleware/http_token_middleware.go index 7fbd007..4918f1d 100644 --- a/internal/reportportal/http_token_middleware.go +++ b/internal/middleware/http_token_middleware.go @@ -1,18 +1,21 @@ -package mcpreportportal +package middleware import ( "context" "log/slog" "net/http" "strings" + + "github.com/reportportal/reportportal-mcp-server/internal/utils" ) +// contextKey is a type for context keys to avoid collisions +type contextKey string + // Context keys for passing data through request context const ( // RPTokenContextKey is used to store RP API token in request context RPTokenContextKey contextKey = "rp_api_token" //nolint:gosec // This is a context key, not a credential - // RPProjectContextKey is used to store RP project parameter in request context - RPProjectContextKey contextKey = "rp_project" //nolint:gosec // This is a context key, not a credential ) // HTTPTokenMiddleware returns an HTTP middleware function that extracts RP API tokens and project parameters @@ -41,7 +44,7 @@ func HTTPTokenMiddleware(next http.Handler) http.Handler { if rpProject != "" { // Add project to request context for use by MCP handlers - r = r.WithContext(WithProjectInContext(r.Context(), rpProject)) + r = r.WithContext(utils.WithProjectInContext(r.Context(), rpProject)) slog.Debug("Extracted RP project parameter from HTTP request", "source", "http_header", @@ -70,7 +73,7 @@ func extractRPTokenFromRequest(r *http.Request) string { token := strings.TrimSpace(parts[1]) // Validate the extracted token before processing - if !ValidateRPToken(token) { + if !utils.ValidateRPToken(token) { slog.Debug("Invalid RP API token rejected", "source", "Authorization Bearer", "validation", "failed") @@ -108,17 +111,3 @@ func extractRPProjectFromRequest(r *http.Request) string { } return "" } - -// WithProjectInContext adds RP project parameter to request context -func WithProjectInContext(ctx context.Context, project string) context.Context { - // Trim whitespace from project parameter - project = strings.TrimSpace(project) - return context.WithValue(ctx, RPProjectContextKey, project) -} - -// GetProjectFromContext extracts RP project parameter from request context -func GetProjectFromContext(ctx context.Context) (string, bool) { - project, ok := ctx.Value(RPProjectContextKey).(string) - res := strings.TrimSpace(project) - return res, ok && res != "" -} diff --git a/internal/reportportal/http_token_middleware_test.go b/internal/middleware/http_token_middleware_test.go similarity index 92% rename from internal/reportportal/http_token_middleware_test.go rename to internal/middleware/http_token_middleware_test.go index 87efda3..093433d 100644 --- a/internal/reportportal/http_token_middleware_test.go +++ b/internal/middleware/http_token_middleware_test.go @@ -1,4 +1,4 @@ -package mcpreportportal +package middleware import ( "context" @@ -7,6 +7,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/reportportal/reportportal-mcp-server/internal/utils" ) func TestExtractRPProjectFromRequest(t *testing.T) { @@ -75,15 +77,15 @@ func TestWithProjectInContext(t *testing.T) { project := "test-project" // Test adding project to context - ctxWithProject := WithProjectInContext(ctx, project) + ctxWithProject := utils.WithProjectInContext(ctx, project) // Test retrieving project from context - retrievedProject, ok := GetProjectFromContext(ctxWithProject) + retrievedProject, ok := utils.GetProjectFromContext(ctxWithProject) assert.True(t, ok) assert.Equal(t, project, retrievedProject) // Test that original context doesn't have project - _, ok = GetProjectFromContext(ctx) + _, ok = utils.GetProjectFromContext(ctx) assert.False(t, ok) } @@ -131,10 +133,10 @@ func TestGetProjectFromContext(t *testing.T) { ctx := context.Background() if tt.contextValue != nil { - ctx = context.WithValue(ctx, RPProjectContextKey, tt.contextValue) + ctx = context.WithValue(ctx, utils.RPProjectContextKey, tt.contextValue) } - project, ok := GetProjectFromContext(ctx) + project, ok := utils.GetProjectFromContext(ctx) assert.Equal(t, tt.expectedOk, ok) assert.Equal(t, tt.expectedProject, project) }) @@ -181,7 +183,7 @@ func TestHTTPTokenMiddleware_ProjectExtraction(t *testing.T) { var projectFound bool testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - project, ok := GetProjectFromContext(r.Context()) + project, ok := utils.GetProjectFromContext(r.Context()) capturedProject = project projectFound = ok w.WriteHeader(http.StatusOK) @@ -228,7 +230,7 @@ func TestHTTPTokenMiddleware_CombinedTokenAndProject(t *testing.T) { capturedToken = token tokenFound = ok - project, ok := GetProjectFromContext(r.Context()) + project, ok := utils.GetProjectFromContext(r.Context()) capturedProject = project projectFound = ok @@ -256,7 +258,7 @@ func TestHTTPTokenMiddleware_NoHeaders(t *testing.T) { testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, tokenFound = GetTokenFromContext(r.Context()) - _, projectFound = GetProjectFromContext(r.Context()) + _, projectFound = utils.GetProjectFromContext(r.Context()) w.WriteHeader(http.StatusOK) }) diff --git a/internal/reportportal/middleware.go b/internal/middleware/middleware.go similarity index 80% rename from internal/reportportal/middleware.go rename to internal/middleware/middleware.go index 47b91e9..b51803d 100644 --- a/internal/reportportal/middleware.go +++ b/internal/middleware/middleware.go @@ -1,6 +1,10 @@ -package mcpreportportal +package middleware -import "net/http" +import ( + "net/http" + + "github.com/reportportal/reportportal-mcp-server/internal/utils" +) func QueryParamsMiddleware(rq *http.Request) { // In HTTP mode, inject the token from request context (extracted from HTTP headers) @@ -10,7 +14,7 @@ func QueryParamsMiddleware(rq *http.Request) { } // Handle query parameters from context - paramsFromContext, ok := QueryParamsFromContext(rq.Context()) + paramsFromContext, ok := utils.QueryParamsFromContext(rq.Context()) if ok && paramsFromContext != nil { // If query parameters are present in the context, add them to the request URL query := rq.URL.Query() diff --git a/internal/reportportal/middleware_test.go b/internal/middleware/middleware_test.go similarity index 78% rename from internal/reportportal/middleware_test.go rename to internal/middleware/middleware_test.go index 815f00b..ae8cb60 100644 --- a/internal/reportportal/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -1,4 +1,4 @@ -package mcpreportportal +package middleware import ( "net/http" @@ -6,6 +6,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/reportportal/reportportal-mcp-server/internal/utils" ) func TestQueryParamsMiddleware(t *testing.T) { @@ -15,7 +17,7 @@ func TestQueryParamsMiddleware(t *testing.T) { params.Add("b", "3") req, _ := http.NewRequest("GET", "http://example.com/path?x=9", nil) - ctx := WithQueryParams(req.Context(), params) + ctx := utils.WithQueryParams(req.Context(), params) req = req.WithContext(ctx) QueryParamsMiddleware(req) diff --git a/internal/reportportal/mock_test.go b/internal/reportportal/mock_test.go deleted file mode 100644 index ca74ddb..0000000 --- a/internal/reportportal/mock_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package mcpreportportal - -import ( - "context" - "strings" - - "github.com/stretchr/testify/assert" -) - -// MockCallToolRequest is a mock implementation of mcp.CallToolRequest for testing -type MockCallToolRequest struct { - project string -} - -// NewMockCallToolRequest creates a new MockCallToolRequest with the specified project -func NewMockCallToolRequest(project string) MockCallToolRequest { - return MockCallToolRequest{project: project} -} - -func (m MockCallToolRequest) RequireString(key string) (string, error) { - if key == "project" { - if m.project == "" { - return "", assert.AnError - } - return m.project, nil - } - return "", assert.AnError -} - -func (m MockCallToolRequest) GetString(key string, defaultValue string) string { - if key == "project" { - return m.project - } - return defaultValue -} - -func (m MockCallToolRequest) GetInt(key string, defaultValue int) int { - return defaultValue -} - -func (m MockCallToolRequest) GetBool(key string, defaultValue bool) bool { - return defaultValue -} - -func (m MockCallToolRequest) GetStringSlice(key string) ([]string, error) { - return nil, assert.AnError -} - -func (m MockCallToolRequest) RequireInt(key string) (int, error) { - return 0, assert.AnError -} - -func (m MockCallToolRequest) RequireBool(key string) (bool, error) { - return false, assert.AnError -} - -func (m MockCallToolRequest) RequireStringSlice(key string) ([]string, error) { - return nil, assert.AnError -} - -// extractProjectWithMock is a test helper that works with MockCallToolRequest -// This mimics the actual extractProject function's priority order -func extractProjectWithMock(ctx context.Context, rq MockCallToolRequest) (string, error) { - // Use project parameter from request (highest priority) - if project := strings.TrimSpace(rq.GetString("project", "")); project != "" { - return project, nil - } - // Fallback to project from context (request's HTTP header or environment variable, depends on MCP mode) - if project, ok := GetProjectFromContext(ctx); ok { - return project, nil - } - return "", assert.AnError -} diff --git a/internal/testutil/mock.go b/internal/testutil/mock.go new file mode 100644 index 0000000..092007f --- /dev/null +++ b/internal/testutil/mock.go @@ -0,0 +1,56 @@ +package testutil + +import ( + "github.com/stretchr/testify/assert" +) + +// MockCallToolRequest is a mock implementation of mcp.CallToolRequest for testing +type MockCallToolRequest struct { + project string +} + +// NewMockCallToolRequest creates a new MockCallToolRequest with the specified project +func NewMockCallToolRequest(project string) MockCallToolRequest { + return MockCallToolRequest{project: project} +} + +func (m *MockCallToolRequest) RequireString(key string) (string, error) { + if key == "project" { + if m.project == "" { + return "", assert.AnError + } + return m.project, nil + } + return "", assert.AnError +} + +func (m *MockCallToolRequest) GetString(key string, defaultValue string) string { + if key == "project" { + return m.project + } + return defaultValue +} + +func (m *MockCallToolRequest) GetInt(key string, defaultValue int) int { + return defaultValue +} + +func (m *MockCallToolRequest) GetBool(key string, defaultValue bool) bool { + return defaultValue +} + +func (m *MockCallToolRequest) GetStringSlice(key string) ([]string, error) { + return nil, assert.AnError +} + +func (m *MockCallToolRequest) RequireInt(key string) (int, error) { + return 0, assert.AnError +} + +func (m *MockCallToolRequest) RequireBool(key string) (bool, error) { + return false, assert.AnError +} + +func (m *MockCallToolRequest) RequireStringSlice(key string) ([]string, error) { + return nil, assert.AnError +} diff --git a/internal/reportportal/ctx_utils.go b/internal/utils/ctx_utils.go similarity index 59% rename from internal/reportportal/ctx_utils.go rename to internal/utils/ctx_utils.go index bea298f..c1bb6b7 100644 --- a/internal/reportportal/ctx_utils.go +++ b/internal/utils/ctx_utils.go @@ -1,4 +1,4 @@ -package mcpreportportal +package utils import ( "context" @@ -11,6 +11,11 @@ import ( // contextKey is a type for context keys to avoid collisions type contextKey string +const ( + // RPProjectContextKey is used to store RP project parameter in request context + RPProjectContextKey contextKey = "rp_project" //nolint:gosec // This is a context key, not a credential +) + var contextKeyQueryParams = contextKey( "queryParams", ) // Key for storing query parameters in the context @@ -26,6 +31,20 @@ func QueryParamsFromContext(ctx context.Context) (url.Values, bool) { return queryParams, ok } +// WithProjectInContext adds RP project parameter to request context +func WithProjectInContext(ctx context.Context, project string) context.Context { + // Trim whitespace from project parameter + project = strings.TrimSpace(project) + return context.WithValue(ctx, RPProjectContextKey, project) +} + +// GetProjectFromContext extracts RP project parameter from request context +func GetProjectFromContext(ctx context.Context) (string, bool) { + project, ok := ctx.Value(RPProjectContextKey).(string) + res := strings.TrimSpace(project) + return res, ok && res != "" +} + // ValidateRPToken performs validation on RP API tokens // Returns true if the token appears to be a valid ReportPortal API token func ValidateRPToken(token string) bool { diff --git a/internal/reportportal/ctx_utils_test.go b/internal/utils/ctx_utils_test.go similarity index 98% rename from internal/reportportal/ctx_utils_test.go rename to internal/utils/ctx_utils_test.go index 8b0959c..4df22b8 100644 --- a/internal/reportportal/ctx_utils_test.go +++ b/internal/utils/ctx_utils_test.go @@ -1,4 +1,4 @@ -package mcpreportportal +package utils import ( "testing" diff --git a/internal/reportportal/utils.go b/internal/utils/utils.go similarity index 73% rename from internal/reportportal/utils.go rename to internal/utils/utils.go index 0d6be0e..693cac3 100644 --- a/internal/reportportal/utils.go +++ b/internal/utils/utils.go @@ -1,4 +1,4 @@ -package mcpreportportal +package utils import ( "context" @@ -15,19 +15,19 @@ import ( ) const ( - firstPage = 1 // Default starting page for pagination - singleResult = 1 // Default number of results per page - defaultPageSize = 50 // Default number of elements per page - defaultSortingForLaunches = "startTime,number,DESC" // default sorting order for launches - defaultSortingForItems = "startTime,DESC" // default sorting order for items - defaultSortingForSuites = "startTime,ASC" // default sorting order for suites - defaultSortingForLogs = "logTime,ASC" // default sorting order for logs - defaultProviderType = "launch" // default provider type - defaultFilterEqHasChildren = "false" // items which don't have children - defaultFilterEqHasStats = "true" - defaultFilterInType = "STEP" - defaultFilterInTypeSuites = "SUITE,TEST" - defaultItemLogLevel = "TRACE" // Default log level for test item logs + FirstPage = 1 // Default starting page for pagination + SingleResult = 1 // Default number of results per page + DefaultPageSize = 50 // Default number of elements per page + DefaultSortingForLaunches = "startTime,number,DESC" // default sorting order for launches + DefaultSortingForItems = "startTime,DESC" // default sorting order for items + DefaultSortingForSuites = "startTime,ASC" // default sorting order for suites + DefaultSortingForLogs = "logTime,ASC" // default sorting order for logs + DefaultProviderType = "launch" // default provider type + DefaultFilterEqHasChildren = "false" // items which don't have children + DefaultFilterEqHasStats = "true" + DefaultFilterInType = "STEP" + DefaultFilterInTypeSuites = "SUITE,TEST" + DefaultItemLogLevel = "TRACE" // Default log level for test item logs ) // PaginatedRequest is a generic interface for API requests that support pagination @@ -37,15 +37,15 @@ type PaginatedRequest[T any] interface { PageSort(string) T } -// setPaginationOptions returns the standard pagination parameters for MCP tools -func setPaginationOptions(sortingParams string) []mcp.ToolOption { +// SetPaginationOptions returns the standard pagination parameters for MCP tools +func SetPaginationOptions(sortingParams string) []mcp.ToolOption { return []mcp.ToolOption{ mcp.WithNumber("page", // Parameter for specifying the page number - mcp.DefaultNumber(firstPage), + mcp.DefaultNumber(FirstPage), mcp.Description("Page number"), ), mcp.WithNumber("page-size", // Parameter for specifying the page size - mcp.DefaultNumber(defaultPageSize), + mcp.DefaultNumber(DefaultPageSize), mcp.Description("Page size"), ), mcp.WithString("page-sort", // Sorting fields and direction @@ -55,20 +55,20 @@ func setPaginationOptions(sortingParams string) []mcp.ToolOption { } } -// applyPaginationOptions extracts pagination from request and applies it to API request -func applyPaginationOptions[T PaginatedRequest[T]]( +// ApplyPaginationOptions extracts pagination from request and applies it to API request +func ApplyPaginationOptions[T PaginatedRequest[T]]( apiRequest T, request mcp.CallToolRequest, sortingParams string, ) T { // Extract the "page" parameter from the request - pageInt := request.GetInt("page", firstPage) + pageInt := request.GetInt("page", FirstPage) if pageInt > math.MaxInt32 { pageInt = math.MaxInt32 } // Extract the "page-size" parameter from the request - pageSizeInt := request.GetInt("page-size", defaultPageSize) + pageSizeInt := request.GetInt("page-size", DefaultPageSize) if pageSizeInt > math.MaxInt32 { pageSizeInt = math.MaxInt32 } @@ -83,7 +83,8 @@ func applyPaginationOptions[T PaginatedRequest[T]]( PageSort(pageSort) } -func extractProject(ctx context.Context, rq mcp.CallToolRequest) (string, error) { +// ExtractProject extracts project name from request or context +func ExtractProject(ctx context.Context, rq mcp.CallToolRequest) (string, error) { // Use project parameter from request if project := strings.TrimSpace(rq.GetString("project", "")); project != "" { return project, nil @@ -97,11 +98,12 @@ func extractProject(ctx context.Context, rq mcp.CallToolRequest) (string, error) ) } -func extractResponseError(err error, rs *http.Response) (errText string) { +// ExtractResponseError extracts error message from HTTP response +func ExtractResponseError(err error, rs *http.Response) (errText string) { errText = err.Error() if rs != nil && rs.Body != nil { // Check if the original error indicates the body is already closed - if isAlreadyClosedError(err) { + if IsAlreadyClosedError(err) { // Don't attempt to read from an already-closed body return errText + " (response body already processed)" } @@ -109,7 +111,7 @@ func extractResponseError(err error, rs *http.Response) (errText string) { defer func() { if closeErr := rs.Body.Close(); closeErr != nil { // Only log close errors if it's not an already-closed body - if !isAlreadyClosedError(closeErr) { + if !IsAlreadyClosedError(closeErr) { errText = errText + " (body close error: " + closeErr.Error() + ")" } } @@ -124,8 +126,8 @@ func extractResponseError(err error, rs *http.Response) (errText string) { return errText } -// Helper function to parse timestamp to Unix epoch -func parseTimestampToEpoch(timestampStr string) (int64, error) { +// ParseTimestampToEpoch parses timestamp to Unix epoch +func ParseTimestampToEpoch(timestampStr string) (int64, error) { if timestampStr == "" { return 0, fmt.Errorf("empty timestamp") } @@ -158,16 +160,16 @@ func parseTimestampToEpoch(timestampStr string) (int64, error) { return 0, fmt.Errorf("unable to parse timestamp: %s", timestampStr) } -// processStartTimeFilter processes start time interval filter and returns the formatted filter string -func processStartTimeFilter(filterStartTimeFrom, filterStartTimeTo string) (string, error) { +// ProcessStartTimeFilter processes start time interval filter and returns the formatted filter string +func ProcessStartTimeFilter(filterStartTimeFrom, filterStartTimeTo string) (string, error) { // Process start time interval filter var filterStartTime string if filterStartTimeFrom != "" && filterStartTimeTo != "" { - fromEpoch, err := parseTimestampToEpoch(filterStartTimeFrom) + fromEpoch, err := ParseTimestampToEpoch(filterStartTimeFrom) if err != nil { return "", fmt.Errorf("invalid from timestamp: %v", err) } - toEpoch, err := parseTimestampToEpoch(filterStartTimeTo) + toEpoch, err := ParseTimestampToEpoch(filterStartTimeTo) if err != nil { return "", fmt.Errorf("invalid to timestamp: %v", err) } @@ -183,8 +185,8 @@ func processStartTimeFilter(filterStartTimeFrom, filterStartTimeTo string) (stri return filterStartTime, nil } -// processAttributeKeys processes attribute keys by adding ":" suffix where needed and combines with existing attributes -func processAttributeKeys(filterAttributes, filterAttributeKeys string) string { +// ProcessAttributeKeys processes attribute keys by adding ":" suffix where needed and combines with existing attributes +func ProcessAttributeKeys(filterAttributes, filterAttributeKeys string) string { if filterAttributeKeys == "" { return filterAttributes } @@ -214,7 +216,8 @@ func processAttributeKeys(filterAttributes, filterAttributeKeys string) string { return result } -func isTextContent(mediaType string) bool { +// IsTextContent checks if media type is text content +func IsTextContent(mediaType string) bool { lowerType := strings.ToLower(mediaType) // Text types (most common) @@ -236,9 +239,9 @@ func isTextContent(mediaType string) bool { return false } -// isAlreadyClosedError checks if the error indicates that the response body is already closed. +// IsAlreadyClosedError checks if the error indicates that the response body is already closed. // This helps avoid unnecessary error logging when closing an already-closed body. -func isAlreadyClosedError(err error) bool { +func IsAlreadyClosedError(err error) bool { if err == nil { return false } @@ -251,9 +254,9 @@ func isAlreadyClosedError(err error) bool { strings.Contains(errStr, "connection closed") } -// readResponseBodyRaw safely reads an HTTP response body and ensures proper cleanup. +// ReadResponseBodyRaw safely reads an HTTP response body and ensures proper cleanup. // It returns the raw body bytes along with any error, suitable for custom content type handling. -func readResponseBodyRaw(response *http.Response) ([]byte, error) { +func ReadResponseBodyRaw(response *http.Response) ([]byte, error) { // Ensure response body is always closed if response == nil || response.Body == nil { return nil, fmt.Errorf("empty HTTP response body") @@ -262,7 +265,7 @@ func readResponseBodyRaw(response *http.Response) ([]byte, error) { if closeErr := response.Body.Close(); closeErr != nil { // Only log if it's not a "already closed" type error // Some HTTP implementations return specific errors for already-closed bodies - if !isAlreadyClosedError(closeErr) { + if !IsAlreadyClosedError(closeErr) { slog.Error("failed to close response body", "error", closeErr) } } @@ -277,11 +280,11 @@ func readResponseBodyRaw(response *http.Response) ([]byte, error) { return rawBody, nil } -// readResponseBody safely reads an HTTP response body and ensures proper cleanup. +// ReadResponseBody safely reads an HTTP response body and ensures proper cleanup. // It handles the defer close pattern with graceful error handling and returns an MCP tool result. -// This is a convenience wrapper around readResponseBodyRaw for MCP tool results. -func readResponseBody(response *http.Response) (*mcp.CallToolResult, error) { - rawBody, err := readResponseBodyRaw(response) +// This is a convenience wrapper around ReadResponseBodyRaw for MCP tool results. +func ReadResponseBody(response *http.Response) (*mcp.CallToolResult, error) { + rawBody, err := ReadResponseBodyRaw(response) if err != nil { return mcp.NewToolResultError( fmt.Sprintf("failed to read response body: %v", err), diff --git a/internal/reportportal/utils_project_test.go b/internal/utils/utils_project_test.go similarity index 96% rename from internal/reportportal/utils_project_test.go rename to internal/utils/utils_project_test.go index 1692a6f..7a917d2 100644 --- a/internal/reportportal/utils_project_test.go +++ b/internal/utils/utils_project_test.go @@ -1,10 +1,12 @@ -package mcpreportportal +package utils import ( "context" "testing" "github.com/stretchr/testify/assert" + + "github.com/reportportal/reportportal-mcp-server/internal/testutil" ) func TestExtractProject(t *testing.T) { @@ -160,12 +162,10 @@ func TestExtractProject(t *testing.T) { } // Create mock request with project parameter - request := MockCallToolRequest{ - project: tt.projectFromRequest, - } + request := testutil.NewMockCallToolRequest(tt.projectFromRequest) // Call extractProject with interface conversion - result, err := extractProjectWithMock(ctx, request) + result, err := ExtractProjectWithMock(ctx, &request) // Verify result if tt.expectError { diff --git a/internal/reportportal/utils_test.go b/internal/utils/utils_test.go similarity index 82% rename from internal/reportportal/utils_test.go rename to internal/utils/utils_test.go index 62a3a56..0fdba61 100644 --- a/internal/reportportal/utils_test.go +++ b/internal/utils/utils_test.go @@ -1,6 +1,8 @@ -package mcpreportportal +package utils import ( + "context" + "fmt" "strings" "testing" ) @@ -137,9 +139,9 @@ func TestProcessAttributeKeys(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := processAttributeKeys(tt.filterAttributes, tt.filterAttributeKeys) + result := ProcessAttributeKeys(tt.filterAttributes, tt.filterAttributeKeys) if result != tt.expected { - t.Errorf("processAttributeKeys(%q, %q) = %q, want %q", + t.Errorf("ProcessAttributeKeys(%q, %q) = %q, want %q", tt.filterAttributes, tt.filterAttributeKeys, result, tt.expected) } }) @@ -158,7 +160,7 @@ func TestProcessAttributeKeys_Performance(t *testing.T) { largeFilterAttributeKeys := strings.Join(keys, ",") // This should not panic or take too long - result := processAttributeKeys(filterAttributes, largeFilterAttributeKeys) + result := ProcessAttributeKeys(filterAttributes, largeFilterAttributeKeys) // Basic validation - should contain the original attributes if !strings.HasPrefix(result, filterAttributes) { @@ -170,3 +172,23 @@ func TestProcessAttributeKeys_Performance(t *testing.T) { t.Errorf("Result should contain many processed keys") } } + +// ExtractProjectWithMock is a test helper that works with testutil.MockCallToolRequest +// This mimics the actual ExtractProject function's priority order +// It's exported so it can be used by test files in other packages +func ExtractProjectWithMock(ctx context.Context, rq interface { + GetString(key string, defaultValue string) string +}, +) (string, error) { + // Use project parameter from request (highest priority) + if project := strings.TrimSpace(rq.GetString("project", "")); project != "" { + return project, nil + } + // Fallback to project from context (request's HTTP header or environment variable, depends on MCP mode) + if project, ok := GetProjectFromContext(ctx); ok { + return project, nil + } + return "", fmt.Errorf( + "no project parameter found in request, HTTP header, or environment variable", + ) +}