Skip to content

Commit 13b9386

Browse files
authored
Merge pull request #351 from smart-mcp-proxy/fix/session-tracking-344
fix: session tracking not updating in Recent Sessions
2 parents d5b3d30 + a015c3c commit 13b9386

5 files changed

Lines changed: 82 additions & 19 deletions

File tree

internal/runtime/lifecycle.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,10 @@ func (r *Runtime) backgroundToolIndexing(ctx context.Context) {
239239
// backgroundSessionCleanup periodically closes sessions that haven't had activity.
240240
// This handles the HTTP transport limitation where OnUnregisterSession is never called.
241241
func (r *Runtime) backgroundSessionCleanup(ctx context.Context) {
242-
// Session inactivity timeout: 5 minutes
243-
// This is a reasonable timeout for MCP sessions where clients typically
244-
// send tool calls every few seconds during active use.
245-
const sessionInactivityTimeout = 5 * time.Minute
242+
// Session inactivity timeout: 30 minutes
243+
// MCP clients may have gaps between tool calls (e.g., user reading results).
244+
// 5 minutes was too aggressive and caused sessions to appear stale.
245+
const sessionInactivityTimeout = 30 * time.Minute
246246

247247
// Check every minute for inactive sessions
248248
ticker := time.NewTicker(1 * time.Minute)

internal/server/mcp.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ type MCPProxyServer struct {
101101

102102
// MCP session tracking
103103
sessionStore *SessionStore
104+
105+
// Hooks shared across all routing mode servers
106+
hooks *mcpserver.Hooks
104107
}
105108

106109
// NewMCPProxyServer creates a new MCP proxy server
@@ -122,6 +125,17 @@ func NewMCPProxyServer(
122125

123126
// Create hooks to capture session information
124127
hooks := &mcpserver.Hooks{}
128+
// Update session activity on every MCP message to prevent premature cleanup.
129+
// Without this, sessions that only do retrieve_tools (not tool calls) would
130+
// appear inactive and get closed by the background cleanup.
131+
hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
132+
session := mcpserver.ClientSessionFromContext(ctx)
133+
if session == nil {
134+
return
135+
}
136+
sessionStore.UpdateActivity(session.SessionID())
137+
})
138+
125139
hooks.AddOnRegisterSession(func(ctx context.Context, sess mcpserver.ClientSession) {
126140
sessionID := sess.SessionID()
127141

@@ -235,6 +249,7 @@ func NewMCPProxyServer(
235249
config: config,
236250
jsPool: jsPool,
237251
sessionStore: sessionStore,
252+
hooks: hooks,
238253
}
239254

240255
// Register proxy tools for the default (retrieve_tools) server

internal/server/mcp_routing.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -453,28 +453,34 @@ func (p *MCPProxyServer) buildCodeExecutionTool() []mcpserver.ServerTool {
453453
// Each server instance has its own set of tools registered appropriate for that mode.
454454
// The main "server" field remains the retrieve_tools mode server (default).
455455
func (p *MCPProxyServer) initRoutingModeServers() {
456+
// All routing mode servers share the same hooks for session tracking
457+
opts := []mcpserver.ServerOption{
458+
mcpserver.WithToolCapabilities(true),
459+
mcpserver.WithRecovery(),
460+
}
461+
if p.hooks != nil {
462+
opts = append(opts, mcpserver.WithHooks(p.hooks))
463+
}
464+
456465
// Create direct mode server
457466
p.directServer = mcpserver.NewMCPServer(
458467
"mcpproxy-go",
459468
mcpServerVersion(),
460-
mcpserver.WithToolCapabilities(true),
461-
mcpserver.WithRecovery(),
469+
opts...,
462470
)
463471

464472
// Create code execution mode server
465473
p.codeExecServer = mcpserver.NewMCPServer(
466474
"mcpproxy-go",
467475
mcpServerVersion(),
468-
mcpserver.WithToolCapabilities(true),
469-
mcpserver.WithRecovery(),
476+
opts...,
470477
)
471478

472479
// Create call tool mode server (/mcp/call)
473480
p.callToolServer = mcpserver.NewMCPServer(
474481
"mcpproxy-go",
475482
mcpServerVersion(),
476-
mcpserver.WithToolCapabilities(true),
477-
mcpserver.WithRecovery(),
483+
opts...,
478484
)
479485

480486
// Register tools for code execution mode (static tools that don't change)

internal/server/session_store.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import (
44
"sync"
55
"time"
66

7-
"go.uber.org/zap"
87
"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
8+
"go.uber.org/zap"
99
)
1010

1111
// SessionInfo holds MCP session metadata
@@ -127,6 +127,16 @@ func (s *SessionStore) UpdateSessionStats(sessionID string, tokens int) {
127127
}
128128
}
129129

130+
// UpdateActivity updates the last activity timestamp for a session without incrementing stats.
131+
func (s *SessionStore) UpdateActivity(sessionID string) {
132+
s.mu.RLock()
133+
defer s.mu.RUnlock()
134+
135+
if s.storageManager != nil {
136+
_ = s.storageManager.UpdateSessionActivity(sessionID)
137+
}
138+
}
139+
130140
// Count returns the number of active sessions
131141
func (s *SessionStore) Count() int {
132142
s.mu.RLock()

internal/storage/manager.go

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -962,8 +962,8 @@ func (m *Manager) CreateSession(session *SessionRecord) error {
962962
c := bucket.Cursor()
963963
for k, v := c.First(); k != nil; k, v = c.Next() {
964964
keyStr := string(k)
965-
// Check if key ends with the session ID (after the underscore)
966-
if len(keyStr) > len(session.ID) && keyStr[len(keyStr)-len(session.ID):] == session.ID {
965+
// Check if key ends with _{session_id}
966+
if strings.HasSuffix(keyStr, "_"+session.ID) {
967967
existingKey = k
968968
if err := json.Unmarshal(v, &existingSession); err != nil {
969969
m.logger.Warnw("Failed to unmarshal existing session", "error", err)
@@ -1033,8 +1033,8 @@ func (m *Manager) CloseSession(sessionID string) error {
10331033
for k, v := c.First(); k != nil; k, v = c.Next() {
10341034
// Key format: {timestamp_ns}_{session_id}
10351035
keyStr := string(k)
1036-
// Check if key ends with the session ID (after the underscore)
1037-
if len(keyStr) > len(sessionID) && keyStr[len(keyStr)-len(sessionID):] == sessionID {
1036+
// Check if key ends with _{session_id}
1037+
if strings.HasSuffix(keyStr, "_"+sessionID) {
10381038
sessionKey = k
10391039
if err := json.Unmarshal(v, &session); err != nil {
10401040
return fmt.Errorf("failed to unmarshal session: %w", err)
@@ -1115,8 +1115,8 @@ func (m *Manager) GetSessionByID(sessionID string) (*SessionRecord, error) {
11151115
c := bucket.Cursor()
11161116
for k, v := c.First(); k != nil; k, v = c.Next() {
11171117
keyStr := string(k)
1118-
// Check if key ends with the session ID (after the underscore)
1119-
if len(keyStr) > len(sessionID) && keyStr[len(keyStr)-len(sessionID):] == sessionID {
1118+
// Check if key ends with _{session_id}
1119+
if strings.HasSuffix(keyStr, "_"+sessionID) {
11201120
var s SessionRecord
11211121
if err := json.Unmarshal(v, &s); err != nil {
11221122
return fmt.Errorf("failed to unmarshal session: %w", err)
@@ -1200,8 +1200,8 @@ func (m *Manager) UpdateSessionStats(sessionID string, tokens int) error {
12001200
c := bucket.Cursor()
12011201
for k, v := c.First(); k != nil; k, v = c.Next() {
12021202
keyStr := string(k)
1203-
// Check if key ends with the session ID (after the underscore)
1204-
if len(keyStr) > len(sessionID) && keyStr[len(keyStr)-len(sessionID):] == sessionID {
1203+
// Check if key ends with _{session_id}
1204+
if strings.HasSuffix(keyStr, "_"+sessionID) {
12051205
sessionKey = k
12061206
if err := json.Unmarshal(v, &session); err != nil {
12071207
return fmt.Errorf("failed to unmarshal session: %w", err)
@@ -1228,6 +1228,38 @@ func (m *Manager) UpdateSessionStats(sessionID string, tokens int) error {
12281228
})
12291229
}
12301230

1231+
// UpdateSessionActivity updates LastActivity without incrementing tool call counts.
1232+
// Call this on any MCP message to keep the session alive.
1233+
func (m *Manager) UpdateSessionActivity(sessionID string) error {
1234+
m.mu.Lock()
1235+
defer m.mu.Unlock()
1236+
1237+
return m.db.db.Update(func(tx *bbolt.Tx) error {
1238+
bucket := tx.Bucket([]byte(SessionsBucket))
1239+
if bucket == nil {
1240+
return nil
1241+
}
1242+
1243+
c := bucket.Cursor()
1244+
for k, v := c.First(); k != nil; k, v = c.Next() {
1245+
keyStr := string(k)
1246+
if strings.HasSuffix(keyStr, "_"+sessionID) {
1247+
var session SessionRecord
1248+
if err := json.Unmarshal(v, &session); err != nil {
1249+
return err
1250+
}
1251+
session.LastActivity = time.Now()
1252+
data, err := json.Marshal(session)
1253+
if err != nil {
1254+
return err
1255+
}
1256+
return bucket.Put(k, data)
1257+
}
1258+
}
1259+
return nil // Session not found is not an error - may not be persisted yet
1260+
})
1261+
}
1262+
12311263
// CloseInactiveSessions closes sessions that haven't had activity for the specified duration
12321264
func (m *Manager) CloseInactiveSessions(inactivityTimeout time.Duration) (int, error) {
12331265
m.mu.Lock()

0 commit comments

Comments
 (0)