Skip to content

Commit 51e072f

Browse files
committed
Add session timeout configuration
Add configurable session timeouts to replace hardcoded values. The config now supports 'sessions.timeout' and 'sessions.cleanupInterval' using Go duration format (e.g., '5m', '30s'). Custom JSON unmarshaling ensures only duration strings are accepted, not numeric values.
1 parent 1098059 commit 51e072f

File tree

5 files changed

+270
-12
lines changed

5 files changed

+270
-12
lines changed

internal/config/load.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"fmt"
66
"os"
77
"strings"
8+
9+
"github.com/dgellow/mcp-front/internal"
810
)
911

1012
// Load loads and processes the config with immediate env var resolution
@@ -129,6 +131,19 @@ func ValidateConfig(config *Config) error {
129131
}
130132
}
131133

134+
// Validate proxy session configuration
135+
if config.Proxy.Sessions != nil {
136+
if config.Proxy.Sessions.Timeout < 0 {
137+
return fmt.Errorf("proxy.sessions.timeout cannot be negative")
138+
}
139+
if config.Proxy.Sessions.CleanupInterval < 0 {
140+
return fmt.Errorf("proxy.sessions.cleanupInterval cannot be negative")
141+
}
142+
if config.Proxy.Sessions.Timeout > 0 && config.Proxy.Sessions.CleanupInterval > config.Proxy.Sessions.Timeout {
143+
internal.LogWarn("Session cleanup interval is greater than session timeout")
144+
}
145+
}
146+
132147
return nil
133148
}
134149

internal/config/load_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package config
22

33
import (
44
"testing"
5+
"time"
56

67
"github.com/stretchr/testify/assert"
78
)
@@ -103,3 +104,106 @@ func TestValidateConfig_UserTokensRequireOAuth(t *testing.T) {
103104
})
104105
}
105106
}
107+
108+
func TestValidateConfig_SessionConfig(t *testing.T) {
109+
tests := []struct {
110+
name string
111+
config *Config
112+
expectError string
113+
expectTimeout time.Duration
114+
expectCleanup time.Duration
115+
}{
116+
{
117+
name: "valid_session_config",
118+
config: &Config{
119+
Proxy: ProxyConfig{
120+
BaseURL: "https://test.example.com",
121+
Addr: ":8080",
122+
Auth: &BearerTokenAuthConfig{
123+
Kind: AuthKindBearerToken,
124+
},
125+
Sessions: &SessionConfig{
126+
Timeout: 10 * time.Minute,
127+
CleanupInterval: 2 * time.Minute,
128+
},
129+
},
130+
MCPServers: map[string]*MCPClientConfig{},
131+
},
132+
expectError: "",
133+
expectTimeout: 10 * time.Minute,
134+
expectCleanup: 2 * time.Minute,
135+
},
136+
{
137+
name: "negative_timeout",
138+
config: &Config{
139+
Proxy: ProxyConfig{
140+
BaseURL: "https://test.example.com",
141+
Addr: ":8080",
142+
Auth: &BearerTokenAuthConfig{
143+
Kind: AuthKindBearerToken,
144+
},
145+
Sessions: &SessionConfig{
146+
Timeout: -1 * time.Minute,
147+
CleanupInterval: 2 * time.Minute,
148+
},
149+
},
150+
MCPServers: map[string]*MCPClientConfig{},
151+
},
152+
expectError: "proxy.sessions.timeout cannot be negative",
153+
},
154+
{
155+
name: "negative_cleanup_interval",
156+
config: &Config{
157+
Proxy: ProxyConfig{
158+
BaseURL: "https://test.example.com",
159+
Addr: ":8080",
160+
Auth: &BearerTokenAuthConfig{
161+
Kind: AuthKindBearerToken,
162+
},
163+
Sessions: &SessionConfig{
164+
Timeout: 10 * time.Minute,
165+
CleanupInterval: -30 * time.Second,
166+
},
167+
},
168+
MCPServers: map[string]*MCPClientConfig{},
169+
},
170+
expectError: "proxy.sessions.cleanupInterval cannot be negative",
171+
},
172+
{
173+
name: "empty_session_config",
174+
config: &Config{
175+
Proxy: ProxyConfig{
176+
BaseURL: "https://test.example.com",
177+
Addr: ":8080",
178+
Auth: &BearerTokenAuthConfig{
179+
Kind: AuthKindBearerToken,
180+
},
181+
Sessions: &SessionConfig{
182+
Timeout: 0,
183+
CleanupInterval: 0,
184+
},
185+
},
186+
MCPServers: map[string]*MCPClientConfig{},
187+
},
188+
expectError: "",
189+
expectTimeout: 0,
190+
expectCleanup: 0,
191+
},
192+
}
193+
194+
for _, tt := range tests {
195+
t.Run(tt.name, func(t *testing.T) {
196+
err := ValidateConfig(tt.config)
197+
if tt.expectError != "" {
198+
assert.Error(t, err)
199+
assert.Contains(t, err.Error(), tt.expectError)
200+
} else {
201+
assert.NoError(t, err)
202+
if tt.config.Proxy.Sessions != nil {
203+
assert.Equal(t, tt.expectTimeout, tt.config.Proxy.Sessions.Timeout)
204+
assert.Equal(t, tt.expectCleanup, tt.config.Proxy.Sessions.CleanupInterval)
205+
}
206+
}
207+
})
208+
}
209+
}

internal/config/types.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ type MCPClientConfig struct {
112112
InlineConfig json.RawMessage `json:"inline,omitempty"`
113113
}
114114

115+
// SessionConfig represents session management configuration
116+
type SessionConfig struct {
117+
Timeout time.Duration
118+
CleanupInterval time.Duration
119+
}
120+
115121
// AdminConfig represents admin UI configuration
116122
type AdminConfig struct {
117123
Enabled bool `json:"enabled"`
@@ -138,11 +144,12 @@ type OAuthAuthConfig struct {
138144

139145
// ProxyConfig represents the proxy configuration with resolved values
140146
type ProxyConfig struct {
141-
BaseURL string `json:"baseURL"`
142-
Addr string `json:"addr"`
143-
Name string `json:"name"`
144-
Auth interface{} `json:"-"` // OAuthAuthConfig or BearerTokenAuthConfig
145-
Admin *AdminConfig `json:"admin,omitempty"`
147+
BaseURL string `json:"baseURL"`
148+
Addr string `json:"addr"`
149+
Name string `json:"name"`
150+
Auth interface{} `json:"-"` // OAuthAuthConfig or BearerTokenAuthConfig
151+
Admin *AdminConfig `json:"admin,omitempty"`
152+
Sessions *SessionConfig `json:"sessions,omitempty"`
146153
}
147154

148155
// Config represents the config structure with resolved values

internal/config/unmarshal.go

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func (c *MCPClientConfig) UnmarshalJSON(data []byte) error {
2020
Env map[string]json.RawMessage `json:"env,omitempty"`
2121
URL json.RawMessage `json:"url,omitempty"`
2222
Headers map[string]json.RawMessage `json:"headers,omitempty"`
23-
Timeout time.Duration `json:"timeout,omitempty"`
23+
Timeout string `json:"timeout,omitempty"`
2424
Options *Options `json:"options,omitempty"`
2525
RequiresUserToken bool `json:"requiresUserToken,omitempty"`
2626
TokenSetup *TokenSetupConfig `json:"tokenSetup,omitempty"`
@@ -33,12 +33,20 @@ func (c *MCPClientConfig) UnmarshalJSON(data []byte) error {
3333
}
3434

3535
c.TransportType = raw.TransportType
36-
c.Timeout = raw.Timeout
3736
c.Options = raw.Options
3837
c.RequiresUserToken = raw.RequiresUserToken
3938
c.TokenSetup = raw.TokenSetup
4039
c.InlineConfig = raw.InlineConfig
4140

41+
// Parse timeout if present
42+
if raw.Timeout != "" {
43+
timeout, err := time.ParseDuration(raw.Timeout)
44+
if err != nil {
45+
return fmt.Errorf("parsing timeout: %w", err)
46+
}
47+
c.Timeout = timeout
48+
}
49+
4250
if c.TransportType == "" {
4351
return fmt.Errorf("transportType is required")
4452
}
@@ -191,11 +199,12 @@ func (o *OAuthAuthConfig) UnmarshalJSON(data []byte) error {
191199
func (p *ProxyConfig) UnmarshalJSON(data []byte) error {
192200
// Use a raw type to parse references
193201
type rawProxy struct {
194-
BaseURL json.RawMessage `json:"baseURL"`
195-
Addr json.RawMessage `json:"addr"`
196-
Name string `json:"name"`
197-
Auth json.RawMessage `json:"auth"`
198-
Admin *AdminConfig `json:"admin"`
202+
BaseURL json.RawMessage `json:"baseURL"`
203+
Addr json.RawMessage `json:"addr"`
204+
Name string `json:"name"`
205+
Auth json.RawMessage `json:"auth"`
206+
Admin *AdminConfig `json:"admin"`
207+
Sessions *SessionConfig `json:"sessions"`
199208
}
200209

201210
var raw rawProxy
@@ -205,6 +214,7 @@ func (p *ProxyConfig) UnmarshalJSON(data []byte) error {
205214

206215
p.Name = raw.Name
207216
p.Admin = raw.Admin
217+
p.Sessions = raw.Sessions
208218

209219
// Normalize admin emails for consistent comparison
210220
if p.Admin != nil && len(p.Admin.AdminEmails) > 0 {
@@ -335,3 +345,35 @@ func (c *MCPClientConfig) ApplyUserToken(userToken string) *MCPClientConfig {
335345

336346
return &result
337347
}
348+
349+
// UnmarshalJSON implements custom unmarshaling for SessionConfig
350+
func (s *SessionConfig) UnmarshalJSON(data []byte) error {
351+
var raw struct {
352+
Timeout string `json:"timeout"`
353+
CleanupInterval string `json:"cleanupInterval"`
354+
}
355+
356+
if err := json.Unmarshal(data, &raw); err != nil {
357+
return err
358+
}
359+
360+
// Parse timeout if present
361+
if raw.Timeout != "" {
362+
timeout, err := time.ParseDuration(raw.Timeout)
363+
if err != nil {
364+
return fmt.Errorf("parsing timeout: %w", err)
365+
}
366+
s.Timeout = timeout
367+
}
368+
369+
// Parse cleanupInterval if present
370+
if raw.CleanupInterval != "" {
371+
interval, err := time.ParseDuration(raw.CleanupInterval)
372+
if err != nil {
373+
return fmt.Errorf("parsing cleanupInterval: %w", err)
374+
}
375+
s.CleanupInterval = interval
376+
}
377+
378+
return nil
379+
}

internal/config/unmarshal_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"os"
66
"testing"
7+
"time"
78

89
"github.com/stretchr/testify/assert"
910
"github.com/stretchr/testify/require"
@@ -329,3 +330,92 @@ func TestOAuthAuthConfig_ValidationErrors(t *testing.T) {
329330
})
330331
}
331332
}
333+
334+
func TestSessionConfig_UnmarshalJSON(t *testing.T) {
335+
tests := []struct {
336+
name string
337+
input string
338+
expectedTimeout time.Duration
339+
expectedCleanup time.Duration
340+
expectedError bool
341+
}{
342+
{
343+
name: "valid durations",
344+
input: `{
345+
"timeout": "10m",
346+
"cleanupInterval": "2m"
347+
}`,
348+
expectedTimeout: 10 * time.Minute,
349+
expectedCleanup: 2 * time.Minute,
350+
},
351+
{
352+
name: "empty strings",
353+
input: `{
354+
"timeout": "",
355+
"cleanupInterval": ""
356+
}`,
357+
expectedTimeout: 0,
358+
expectedCleanup: 0,
359+
},
360+
{
361+
name: "missing fields",
362+
input: `{}`,
363+
expectedTimeout: 0,
364+
expectedCleanup: 0,
365+
},
366+
{
367+
name: "invalid duration format",
368+
input: `{
369+
"timeout": "invalid",
370+
"cleanupInterval": "2m"
371+
}`,
372+
expectedError: true,
373+
},
374+
{
375+
name: "numeric values rejected",
376+
input: `{
377+
"timeout": 600000000000,
378+
"cleanupInterval": 120000000000
379+
}`,
380+
expectedError: true,
381+
},
382+
}
383+
384+
for _, tt := range tests {
385+
t.Run(tt.name, func(t *testing.T) {
386+
var config SessionConfig
387+
err := json.Unmarshal([]byte(tt.input), &config)
388+
389+
if tt.expectedError {
390+
assert.Error(t, err)
391+
} else {
392+
assert.NoError(t, err)
393+
assert.Equal(t, tt.expectedTimeout, config.Timeout)
394+
assert.Equal(t, tt.expectedCleanup, config.CleanupInterval)
395+
}
396+
})
397+
}
398+
}
399+
400+
func TestProxyConfig_SessionConfigIntegration(t *testing.T) {
401+
input := `{
402+
"baseURL": "http://localhost:8080",
403+
"addr": ":8080",
404+
"auth": {
405+
"kind": "bearerToken",
406+
"tokens": {}
407+
},
408+
"sessions": {
409+
"timeout": "15m",
410+
"cleanupInterval": "3m"
411+
}
412+
}`
413+
414+
var config ProxyConfig
415+
err := json.Unmarshal([]byte(input), &config)
416+
require.NoError(t, err)
417+
418+
require.NotNil(t, config.Sessions)
419+
assert.Equal(t, 15*time.Minute, config.Sessions.Timeout)
420+
assert.Equal(t, 3*time.Minute, config.Sessions.CleanupInterval)
421+
}

0 commit comments

Comments
 (0)