From bc9cf6cb4a678fb357db2fc80a281c5b4ac78f19 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:49:30 +0000 Subject: [PATCH] feat(envutil): add GetEnvDuration + configurable MCP session timeout Add GetEnvDuration to envutil, consistent with GetEnvString/GetEnvInt/GetEnvBool. Use it in server/transport.go to make the unified-mode session timeout configurable via MCP_GATEWAY_SESSION_TIMEOUT (default: 2h). Previously the 2h timeout was hardcoded. Operators with tighter security requirements or faster workflows can now shorten it without recompiling. Format: any string accepted by time.ParseDuration, e.g. '30m', '1h', '4h'. Invalid/zero/negative values fall back to the 2h default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/envutil/envutil.go | 13 ++++ internal/envutil/envutil_test.go | 120 +++++++++++++++++++++++++++++++ internal/server/transport.go | 7 +- 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/internal/envutil/envutil.go b/internal/envutil/envutil.go index b4f29432..263ac101 100644 --- a/internal/envutil/envutil.go +++ b/internal/envutil/envutil.go @@ -4,6 +4,7 @@ import ( "os" "strconv" "strings" + "time" ) // GetEnvString returns the value of the environment variable specified by envKey. @@ -28,6 +29,18 @@ func GetEnvInt(envKey string, defaultValue int) int { return defaultValue } +// GetEnvDuration returns the time.Duration value of the environment variable specified by envKey. +// If the environment variable is not set, is empty, or cannot be parsed by time.ParseDuration, +// it returns the defaultValue. Accepts any string valid for time.ParseDuration (e.g. "2h", "30m", "90s"). +func GetEnvDuration(envKey string, defaultValue time.Duration) time.Duration { + if envValue := os.Getenv(envKey); envValue != "" { + if d, err := time.ParseDuration(envValue); err == nil && d > 0 { + return d + } + } + return defaultValue +} + // GetEnvBool returns the boolean value of the environment variable specified by envKey. // If the environment variable is not set or is empty, it returns the defaultValue. // Truthy values (case-insensitive): "1", "true", "yes", "on" diff --git a/internal/envutil/envutil_test.go b/internal/envutil/envutil_test.go index 6a9a5277..c1230d58 100644 --- a/internal/envutil/envutil_test.go +++ b/internal/envutil/envutil_test.go @@ -3,10 +3,130 @@ package envutil import ( "os" "testing" + "time" "github.com/stretchr/testify/assert" ) +func TestGetEnvDuration(t *testing.T) { + tests := []struct { + name string + envKey string + envValue string + setEnv bool + defaultValue time.Duration + expected time.Duration + }{ + { + name: "env var set to '2h' - returns 2 hours", + envKey: "TEST_DURATION_VAR", + envValue: "2h", + setEnv: true, + defaultValue: 30 * time.Minute, + expected: 2 * time.Hour, + }, + { + name: "env var set to '30m' - returns 30 minutes", + envKey: "TEST_DURATION_VAR", + envValue: "30m", + setEnv: true, + defaultValue: 2 * time.Hour, + expected: 30 * time.Minute, + }, + { + name: "env var set to '90s' - returns 90 seconds", + envKey: "TEST_DURATION_VAR", + envValue: "90s", + setEnv: true, + defaultValue: 2 * time.Hour, + expected: 90 * time.Second, + }, + { + name: "env var not set - returns default", + envKey: "TEST_DURATION_VAR", + setEnv: false, + defaultValue: 2 * time.Hour, + expected: 2 * time.Hour, + }, + { + name: "env var empty string - returns default", + envKey: "TEST_DURATION_VAR", + envValue: "", + setEnv: true, + defaultValue: 2 * time.Hour, + expected: 2 * time.Hour, + }, + { + name: "env var with invalid value - returns default", + envKey: "TEST_DURATION_VAR", + envValue: "invalid", + setEnv: true, + defaultValue: 2 * time.Hour, + expected: 2 * time.Hour, + }, + { + name: "env var with zero duration - returns default", + envKey: "TEST_DURATION_VAR", + envValue: "0s", + setEnv: true, + defaultValue: 2 * time.Hour, + expected: 2 * time.Hour, + }, + { + name: "env var with negative duration - returns default", + envKey: "TEST_DURATION_VAR", + envValue: "-1h", + setEnv: true, + defaultValue: 2 * time.Hour, + expected: 2 * time.Hour, + }, + { + name: "env var with mixed units - returns parsed duration", + envKey: "TEST_DURATION_VAR", + envValue: "1h30m", + setEnv: true, + defaultValue: 2 * time.Hour, + expected: 90 * time.Minute, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Unsetenv(tt.envKey) + defer os.Unsetenv(tt.envKey) + + if tt.setEnv { + os.Setenv(tt.envKey, tt.envValue) + } + + result := GetEnvDuration(tt.envKey, tt.defaultValue) + assert.Equal(t, tt.expected, result) + }) + } +} + +// TestGetEnvDurationRealWorldScenarios tests realistic usage scenarios +func TestGetEnvDurationRealWorldScenarios(t *testing.T) { + t.Run("session timeout configuration", func(t *testing.T) { + os.Unsetenv("MCP_GATEWAY_SESSION_TIMEOUT") + defer os.Unsetenv("MCP_GATEWAY_SESSION_TIMEOUT") + + // Default case + result := GetEnvDuration("MCP_GATEWAY_SESSION_TIMEOUT", 2*time.Hour) + assert.Equal(t, 2*time.Hour, result) + + // Override with shorter timeout + os.Setenv("MCP_GATEWAY_SESSION_TIMEOUT", "30m") + result = GetEnvDuration("MCP_GATEWAY_SESSION_TIMEOUT", 2*time.Hour) + assert.Equal(t, 30*time.Minute, result) + + // Override with longer timeout + os.Setenv("MCP_GATEWAY_SESSION_TIMEOUT", "4h") + result = GetEnvDuration("MCP_GATEWAY_SESSION_TIMEOUT", 2*time.Hour) + assert.Equal(t, 4*time.Hour, result) + }) +} + func TestGetEnvString(t *testing.T) { tests := []struct { name string diff --git a/internal/server/transport.go b/internal/server/transport.go index 81dd560d..478e3fb8 100644 --- a/internal/server/transport.go +++ b/internal/server/transport.go @@ -4,6 +4,7 @@ import ( "net/http" "time" + "github.com/github/gh-aw-mcpg/internal/envutil" "github.com/github/gh-aw-mcpg/internal/logger" sdk "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -35,9 +36,9 @@ func CreateHTTPServerForMCP(addr string, unifiedServer *UnifiedServer, apiKey st return unifiedServer.server }, &sdk.StreamableHTTPOptions{ - Stateless: false, // Support stateful sessions - Logger: logger.NewSlogLoggerWithHandler(logTransport), // Integrate SDK logging with project logger - SessionTimeout: 2 * time.Hour, // 2h accommodates long-running workflows with idle periods + Stateless: false, // Support stateful sessions + Logger: logger.NewSlogLoggerWithHandler(logTransport), // Integrate SDK logging with project logger + SessionTimeout: envutil.GetEnvDuration("MCP_GATEWAY_SESSION_TIMEOUT", 2*time.Hour), // Configurable; 2h default accommodates long-running workflows with idle periods }) // Apply standard middleware stack (SDK logging → shutdown check → auth)