Skip to content

Commit d58513d

Browse files
tehranianclaude
andauthored
feat: add --session-idle-timeout-minutes CLI flag (#691)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c3d4c14 commit d58513d

3 files changed

Lines changed: 82 additions & 5 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,9 @@ The `mcp-grafana` binary supports various command-line flags for configuration:
335335
- `--metrics`: Enable Prometheus metrics endpoint at `/metrics`
336336
- `--metrics-address`: Separate address for metrics server (e.g., `:9090`). If empty, metrics are served on the main server
337337

338+
**Session Management:**
339+
- `--session-idle-timeout-minutes`: Session idle timeout in minutes. Sessions with no activity for this duration are automatically reaped - default: `30`. Set to `0` to disable session reaping. Only relevant for SSE and streamable-http transports.
340+
338341
**Tool Configuration:**
339342
- `--enabled-tools`: Comma-separated list of enabled categories - default: all categories except `admin`, to enable admin tools, add `admin` to the list (e.g., `"search,datasource,...,admin"`)
340343
- `--max-loki-log-limit`: Maximum number of log lines returned per `query_loki_logs` call - default: `100`. Note: Set this at least 1 below Loki's server-side `max_entries_limit_per_query` to allow truncation detection (the tool requests `limit+1` internally to detect if more data exists).

cmd/mcp-grafana/main.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,10 @@ func (dt *disabledTools) addTools(s *server.MCPServer) {
131131
maybeAddTools(s, tools.AddRunPanelQueryTools, enabledTools, dt.runpanelquery, "runpanelquery")
132132
}
133133

134-
func newServer(transport string, dt disabledTools, obs *observability.Observability) (*server.MCPServer, *mcpgrafana.ToolManager, *mcpgrafana.SessionManager) {
135-
sm := mcpgrafana.NewSessionManager()
134+
func newServer(transport string, dt disabledTools, obs *observability.Observability, sessionIdleTimeoutMinutes int) (*server.MCPServer, *mcpgrafana.ToolManager, *mcpgrafana.SessionManager) {
135+
sm := mcpgrafana.NewSessionManager(
136+
mcpgrafana.WithSessionTTL(time.Duration(sessionIdleTimeoutMinutes) * time.Minute),
137+
)
136138

137139
// Declare variable for ToolManager that will be initialized after server creation
138140
var stm *mcpgrafana.ToolManager
@@ -277,7 +279,7 @@ func runMetricsServer(addr string, o *observability.Observability) {
277279
}
278280
}
279281

280-
func run(transport, addr, basePath, endpointPath string, logLevel slog.Level, dt disabledTools, gc mcpgrafana.GrafanaConfig, tls tlsConfig, obs observability.Config) error {
282+
func run(transport, addr, basePath, endpointPath string, logLevel slog.Level, dt disabledTools, gc mcpgrafana.GrafanaConfig, tls tlsConfig, obs observability.Config, sessionIdleTimeoutMinutes int) error {
281283
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})))
282284

283285
// Set up observability (metrics and tracing)
@@ -301,7 +303,7 @@ func run(transport, addr, basePath, endpointPath string, logLevel slog.Level, dt
301303
defer clientCache.Close()
302304
}
303305

304-
s, tm, sm := newServer(transport, dt, o)
306+
s, tm, sm := newServer(transport, dt, o, sessionIdleTimeoutMinutes)
305307
defer sm.Close()
306308

307309
// Create a context that will be cancelled on shutdown
@@ -416,6 +418,7 @@ func main() {
416418
basePath := flag.String("base-path", "", "Base path for the sse server")
417419
endpointPath := flag.String("endpoint-path", "/mcp", "Endpoint path for the streamable-http server")
418420
logLevel := flag.String("log-level", "info", "Log level (debug, info, warn, error)")
421+
sessionIdleTimeoutMinutes := flag.Int("session-idle-timeout-minutes", 30, "Session idle timeout in minutes. Sessions with no activity for this duration are automatically reaped. Set to 0 to disable session reaping")
419422
showVersion := flag.Bool("version", false, "Print the version and exit")
420423
var dt disabledTools
421424
dt.addFlags()
@@ -459,7 +462,7 @@ func main() {
459462
obs.NetworkTransport = mcpconv.NetworkTransportTCP
460463
}
461464

462-
if err := run(transport, *addr, *basePath, *endpointPath, parseLevel(*logLevel), dt, grafanaConfig, tls, obs); err != nil {
465+
if err := run(transport, *addr, *basePath, *endpointPath, parseLevel(*logLevel), dt, grafanaConfig, tls, obs, *sessionIdleTimeoutMinutes); err != nil {
463466
panic(err)
464467
}
465468
}

cmd/mcp-grafana/main_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"testing"
6+
"testing/synctest"
7+
"time"
8+
9+
"github.com/mark3labs/mcp-go/mcp"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/grafana/mcp-grafana/observability"
14+
)
15+
16+
// testClientSession implements server.ClientSession for unit tests.
17+
type testClientSession struct {
18+
id string
19+
}
20+
21+
func (s *testClientSession) SessionID() string { return s.id }
22+
func (s *testClientSession) NotificationChannel() chan<- mcp.JSONRPCNotification { return nil }
23+
func (s *testClientSession) Initialize() {}
24+
func (s *testClientSession) Initialized() bool { return true }
25+
26+
func newTestObservability(t *testing.T) *observability.Observability {
27+
t.Helper()
28+
obs, err := observability.Setup(observability.Config{})
29+
require.NoError(t, err)
30+
t.Cleanup(func() {
31+
_ = obs.Shutdown(context.Background())
32+
})
33+
return obs
34+
}
35+
36+
func TestNewServer_SessionIdleTimeoutZeroDisablesReaping(t *testing.T) {
37+
obs := newTestObservability(t)
38+
synctest.Test(t, func(t *testing.T) {
39+
_, _, sm := newServer("stdio", disabledTools{enabledTools: "search"}, obs, 0)
40+
defer sm.Close()
41+
42+
session := &testClientSession{id: "should-persist"}
43+
sm.CreateSession(context.Background(), session)
44+
45+
// Advance the fake clock well beyond any reasonable reaper interval.
46+
// With reaper disabled (TTL=0), the session must survive.
47+
time.Sleep(time.Hour)
48+
49+
_, exists := sm.GetSession("should-persist")
50+
assert.True(t, exists, "Session should persist when idle timeout is 0 (reaper disabled)")
51+
})
52+
}
53+
54+
func TestNewServer_SessionIdleTimeoutCustomValue(t *testing.T) {
55+
obs := newTestObservability(t)
56+
synctest.Test(t, func(t *testing.T) {
57+
_, _, sm := newServer("stdio", disabledTools{enabledTools: "search"}, obs, 1)
58+
defer sm.Close()
59+
60+
session := &testClientSession{id: "custom-ttl"}
61+
sm.CreateSession(context.Background(), session)
62+
63+
// Advance the fake clock past the 1-minute TTL.
64+
// The reaper runs every TTL/2 (30s), so by 2 minutes
65+
// it will have fired and reaped the idle session.
66+
time.Sleep(2 * time.Minute)
67+
68+
_, exists := sm.GetSession("custom-ttl")
69+
assert.False(t, exists, "Session should be reaped after exceeding the 1-minute idle timeout")
70+
})
71+
}

0 commit comments

Comments
 (0)