Skip to content

Commit d62c210

Browse files
JAORMXclaude
andcommitted
Fix three session timeout bugs in proxy transports
Sessions were evicted by the TTL cleanup goroutine even while actively in use because session activity was not being recorded in the right places: 1. SSE keep-alive ticks now call sessionManager.Get to refresh the TTL while the SSE socket is open, so the cleanup goroutine does not evict clients that have not sent a POST request recently. 2. Single notifications/client-responses forwarded via the streamable HTTP proxy now also refresh the session TTL; previously the session header was not read from the request in that code path. 3. Removed ephemeral session creation in the streamable proxy for requests that arrive without an Mcp-Session-Id header. The old code created a UUID-keyed session per request and never cleaned it up, leaking memory over time. Sessionless requests are now routed with an empty sessID that is used only for in-process waiter-channel correlation; no persistent session object is created. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1595a32 commit d62c210

2 files changed

Lines changed: 20 additions & 20 deletions

File tree

pkg/transport/proxy/httpsse/http_proxy.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,9 @@ func (p *HTTPSSEProxy) handleSSEConnection(w http.ResponseWriter, r *http.Reques
447447
}
448448
flusher.Flush()
449449
case <-keepAliveTicker.C:
450-
// Send SSE comment as keep-alive
450+
// Refresh session TTL while the SSE socket is open so the cleanup
451+
// goroutine does not evict clients that haven't sent a POST recently.
452+
p.sessionManager.Get(clientID)
451453
if _, err := fmt.Fprint(w, ": keep-alive\n\n"); err != nil {
452454
slog.Debug("failed to write keep-alive", "error", err)
453455
return

pkg/transport/proxy/streamable/streamable_proxy.go

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ func (p *HTTPProxy) handlePost(w http.ResponseWriter, r *http.Request) {
318318
}
319319

320320
// Notifications or client responses are accepted and forwarded (202)
321-
if p.handleNotificationOrClientResponse(w, msg) {
321+
if p.handleNotificationOrClientResponse(w, r.Header.Get("Mcp-Session-Id"), msg) {
322322
return
323323
}
324324

@@ -641,17 +641,14 @@ func (p *HTTPProxy) ensureSession(id string) error {
641641
return p.sessionManager.AddWithID(id)
642642
}
643643

644-
// resolveSessionForBatch resolves or creates an ephemeral session for batch POSTs.
644+
// resolveSessionForBatch resolves the session for batch POSTs.
645645
// Writes appropriate HTTP errors and returns an error when handling should stop.
646+
// An absent session header is allowed (returns "", nil) so that notification-only
647+
// batches can still return 202 without requiring a session.
646648
func (p *HTTPProxy) resolveSessionForBatch(w http.ResponseWriter, r *http.Request) (string, error) {
647649
sessID := r.Header.Get("Mcp-Session-Id")
648650
if sessID == "" {
649-
sessID = uuid.New().String()
650-
if err := p.ensureSession(sessID); err != nil {
651-
writeHTTPError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create session: %v", err))
652-
return "", err
653-
}
654-
return sessID, nil
651+
return "", nil
655652
}
656653
if _, ok := p.sessionManager.Get(sessID); !ok {
657654
session.WriteNotFound(w, nil)
@@ -661,8 +658,9 @@ func (p *HTTPProxy) resolveSessionForBatch(w http.ResponseWriter, r *http.Reques
661658
}
662659

663660
// resolveSessionForRequest resolves session rules for a single JSON-RPC request.
664-
// On initialize, assigns session if missing and returns setSessionHeader=true.
665-
// For other methods, allows optional session by creating ephemeral (no header set).
661+
// On initialize, assigns a new session ID if none is provided and returns setSessionHeader=true.
662+
// For other methods, allows sessionless requests (sessID="") for in-process routing;
663+
// a provided but unknown session ID returns 404.
666664
// Writes HTTP errors on failure and returns error to stop handling.
667665
func (p *HTTPProxy) resolveSessionForRequest(
668666
w http.ResponseWriter,
@@ -684,17 +682,13 @@ func (p *HTTPProxy) resolveSessionForRequest(
684682
return sessID, setSessionHeader, nil
685683
}
686684

687-
// Non-initialize path: sessions are optional; create ephemeral if missing
685+
// Non-initialize requests without a session ID are allowed in sessionless mode
686+
// (no session object created, composite key uses empty sessID for in-process routing).
688687
if sessID == "" {
689-
sessID = uuid.New().String()
690-
if err := p.ensureSession(sessID); err != nil {
691-
writeHTTPError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create session: %v", err))
692-
return "", false, err
693-
}
694-
return sessID, false, nil
688+
return "", false, nil
695689
}
696690

697-
// If session is provided, ensure it exists
691+
// Session ID provided but not found: reject with 404.
698692
if _, ok := p.sessionManager.Get(sessID); !ok {
699693
session.WriteNotFound(w, req.ID.Raw())
700694
return "", false, fmt.Errorf("session not found")
@@ -730,8 +724,12 @@ func decodeJSONRPCMessage(w http.ResponseWriter, body []byte) (jsonrpc2.Message,
730724
return msg, true
731725
}
732726

733-
func (p *HTTPProxy) handleNotificationOrClientResponse(w http.ResponseWriter, msg jsonrpc2.Message) bool {
727+
func (p *HTTPProxy) handleNotificationOrClientResponse(w http.ResponseWriter, sessID string, msg jsonrpc2.Message) bool {
734728
if isNotification(msg) || (func() bool { _, ok := msg.(*jsonrpc2.Response); return ok })() {
729+
// Refresh TTL so a client sending only notifications doesn't get evicted.
730+
if sessID != "" {
731+
p.sessionManager.Get(sessID)
732+
}
735733
if err := p.SendMessageToDestination(msg); err != nil {
736734
slog.Error("failed to send message to destination", "error", err)
737735
}

0 commit comments

Comments
 (0)