Skip to content

Commit 2a23f4a

Browse files
authored
fix: create StatelessGeneratingSessionIdManager to fix multi-instance deployments (#641)
* fix: restore StatelessSessionIdManager as default to fix multi-instance deployments Fixes #636 In commit da6f722, the default session ID manager was changed from StatelessSessionIdManager to InsecureStatefulSessionIdManager, which broke multi-instance deployments without sticky sessions by causing Invalid session ID errors when requests were routed to different server instances. This change restores the previous default behavior: - Default session manager is now StatelessSessionIdManager (no session validation) - Multi-instance deployments work without requiring sticky sessions - Added WithStateful() option for explicit stateful session management - Updated all nil fallbacks to use stateless manager - Updated tests to verify new default behavior - Updated documentation to reflect the change Backward compatibility is maintained while fixing the production deployment issue. * fix: create StatelessGeneratingSessionIdManager to fix multi-instance deployments Fixes #636 The original fix was too aggressive - changing to completely stateless broke existing functionality that expects session IDs to be generated. New approach: - Created StatelessGeneratingSessionIdManager that generates session IDs but only validates format (not existence locally) - This fixes multi-instance deployments while maintaining compatibility - Updated tests to reflect new behavior - Session termination now requires explicit stateful mode This solves the production issue where requests routed to different instances failed with 'Invalid session ID' errors while preserving the expected session ID generation behavior. * fix: update NewDefaultSessionIdManagerResolver fallback to StatelessSessionIdManager As requested, updated NewDefaultSessionIdManagerResolver to fall back to StatelessSessionIdManager instead of StatelessGeneratingSessionIdManager when manager is nil. This change affects: - NewDefaultSessionIdManagerResolver(nil) fallback - WithSessionIdManager(nil) fallback - WithSessionIdManagerResolver(nil) fallback Updated corresponding tests to expect stateless behavior: - Generate() returns empty string ("") - Validate() accepts any session ID without error The server default still uses StatelessGeneratingSessionIdManager for backward compatibility, but nil fallbacks now use truly stateless behavior. * fix: update tests to use WithStateful(true) for stateful behavior As requested in the CodeRabbit review, updated tests that expect stateful behavior to explicitly use WithStateful(true) instead of relying on the old default behavior. Updated tests: - TestStreamableHTTP_POST_SendAndReceive: expects session IDs in headers and 400 for invalid IDs - TestStreamableHttpResourceGet: expects session ID in header - TestStreamableHTTP_SessionWithLogging: expects session ID in header - TestStreamableHTTP_SendNotificationToSpecificClient: both subtests expect session IDs in headers This ensures tests explicitly opt into stateful mode when needed, while the default remains stateless for multi-instance deployments. * fix: enable coverage on main branch and add artifact retention - Updated coverage job condition to run on push to main branch in addition to PRs - Added 30-day artifact retention to ensure coverage artifacts are available for comparison - This fixes the coverage triggers that were failing due to missing baseline artifacts Now coverage will: 1. Run on main branch pushes to create baseline artifacts 2. Run on PRs to compare against baseline and ensure coverage doesn't decrease 3. Keep artifacts available for 30 days for proper comparison
1 parent ecc6d8f commit 2a23f4a

File tree

3 files changed

+142
-45
lines changed

3 files changed

+142
-45
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919

2020
coverage:
2121
runs-on: ubuntu-latest
22-
if: github.event_name == 'pull_request' || github.event_name == 'pull_request_target'
22+
if: github.event_name == 'pull_request' || github.event_name == 'pull_request_target' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
2323
permissions:
2424
contents: read
2525
pull-requests: write
@@ -38,6 +38,7 @@ jobs:
3838
with:
3939
name: code-coverage
4040
path: coverage.txt
41+
retention-days: 30
4142
- name: Generate coverage report
4243
uses: fgrosse/[email protected]
4344

server/streamable_http.go

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ func WithStateLess(stateLess bool) StreamableHTTPOption {
5252
}
5353

5454
// WithSessionIdManager sets a custom session id generator for the server.
55-
// By default, the server uses InsecureStatefulSessionIdManager (UUID-based; insecure).
55+
// By default, the server uses StatelessGeneratingSessionIdManager (generates IDs but no local validation).
5656
// Note: Options are applied in order; the last one wins. If combined with
5757
// WithStateLess or WithSessionIdManagerResolver, whichever is applied last takes effect.
5858
func WithSessionIdManager(manager SessionIdManager) StreamableHTTPOption {
5959
return func(s *StreamableHTTPServer) {
6060
if manager == nil {
61-
s.sessionIdManagerResolver = NewDefaultSessionIdManagerResolver(&InsecureStatefulSessionIdManager{})
61+
s.sessionIdManagerResolver = NewDefaultSessionIdManagerResolver(&StatelessSessionIdManager{})
6262
return
6363
}
6464
s.sessionIdManagerResolver = NewDefaultSessionIdManagerResolver(manager)
@@ -72,13 +72,23 @@ func WithSessionIdManager(manager SessionIdManager) StreamableHTTPOption {
7272
func WithSessionIdManagerResolver(resolver SessionIdManagerResolver) StreamableHTTPOption {
7373
return func(s *StreamableHTTPServer) {
7474
if resolver == nil {
75-
s.sessionIdManagerResolver = NewDefaultSessionIdManagerResolver(&InsecureStatefulSessionIdManager{})
75+
s.sessionIdManagerResolver = NewDefaultSessionIdManagerResolver(&StatelessSessionIdManager{})
7676
return
7777
}
7878
s.sessionIdManagerResolver = resolver
7979
}
8080
}
8181

82+
// WithStateful enables stateful session management using InsecureStatefulSessionIdManager.
83+
// This requires sticky sessions in multi-instance deployments.
84+
func WithStateful(stateful bool) StreamableHTTPOption {
85+
return func(s *StreamableHTTPServer) {
86+
if stateful {
87+
s.sessionIdManagerResolver = NewDefaultSessionIdManagerResolver(&InsecureStatefulSessionIdManager{})
88+
}
89+
}
90+
}
91+
8292
// WithHeartbeatInterval sets the heartbeat interval. Positive interval means the
8393
// server will send a heartbeat to the client through the GET connection, to keep
8494
// the connection alive from being closed by the network infrastructure (e.g.
@@ -187,7 +197,7 @@ func NewStreamableHTTPServer(server *MCPServer, opts ...StreamableHTTPOption) *S
187197
sessionTools: newSessionToolsStore(),
188198
sessionLogLevels: newSessionLogLevelsStore(),
189199
endpointPath: "/mcp",
190-
sessionIdManagerResolver: NewDefaultSessionIdManagerResolver(&InsecureStatefulSessionIdManager{}),
200+
sessionIdManagerResolver: NewDefaultSessionIdManagerResolver(&StatelessGeneratingSessionIdManager{}),
191201
logger: util.DefaultLogger(),
192202
sessionResources: newSessionResourcesStore(),
193203
sessionResourceTemplates: newSessionResourceTemplatesStore(),
@@ -1244,7 +1254,7 @@ type DefaultSessionIdManagerResolver struct {
12441254
// NewDefaultSessionIdManagerResolver creates a new DefaultSessionIdManagerResolver with the given SessionIdManager
12451255
func NewDefaultSessionIdManagerResolver(manager SessionIdManager) *DefaultSessionIdManagerResolver {
12461256
if manager == nil {
1247-
manager = &InsecureStatefulSessionIdManager{}
1257+
manager = &StatelessSessionIdManager{}
12481258
}
12491259
return &DefaultSessionIdManagerResolver{manager: manager}
12501260
}
@@ -1270,6 +1280,30 @@ func (s *StatelessSessionIdManager) Terminate(sessionID string) (isNotAllowed bo
12701280
return false, nil
12711281
}
12721282

1283+
// StatelessGeneratingSessionIdManager generates session IDs but doesn't validate them locally.
1284+
// This allows session IDs to be generated for clients while working across multiple instances.
1285+
type StatelessGeneratingSessionIdManager struct{}
1286+
1287+
func (s *StatelessGeneratingSessionIdManager) Generate() string {
1288+
return idPrefix + uuid.New().String()
1289+
}
1290+
1291+
func (s *StatelessGeneratingSessionIdManager) Validate(sessionID string) (isTerminated bool, err error) {
1292+
// Only validate format, not existence - allows cross-instance operation
1293+
if !strings.HasPrefix(sessionID, idPrefix) {
1294+
return false, fmt.Errorf("invalid session id: %s", sessionID)
1295+
}
1296+
if _, err := uuid.Parse(sessionID[len(idPrefix):]); err != nil {
1297+
return false, fmt.Errorf("invalid session id: %s", sessionID)
1298+
}
1299+
return false, nil
1300+
}
1301+
1302+
func (s *StatelessGeneratingSessionIdManager) Terminate(sessionID string) (isNotAllowed bool, err error) {
1303+
// No-op termination since we don't track sessions
1304+
return false, nil
1305+
}
1306+
12731307
// InsecureStatefulSessionIdManager generate id with uuid and tracks active sessions.
12741308
// It validates both format and existence of session IDs.
12751309
// For more secure session id, use a more complex generator, like a JWT.

server/streamable_http_test.go

Lines changed: 101 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ func TestStreamableHTTP_POST_InvalidContent(t *testing.T) {
125125
func TestStreamableHTTP_POST_SendAndReceive(t *testing.T) {
126126
mcpServer := NewMCPServer("test-mcp-server", "1.0")
127127
addSSETool(mcpServer)
128-
server := NewTestStreamableHTTPServer(mcpServer)
128+
server := NewTestStreamableHTTPServer(mcpServer, WithStateful(true))
129129
var sessionID string
130130

131131
t.Run("initialize", func(t *testing.T) {
@@ -595,6 +595,7 @@ func TestStreamableHttpResourceGet(t *testing.T) {
595595

596596
testServer := NewTestStreamableHTTPServer(
597597
s,
598+
WithStateful(true),
598599
WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context {
599600
session := ClientSessionFromContext(ctx)
600601

@@ -1014,7 +1015,7 @@ func TestStreamableHTTP_SessionWithLogging(t *testing.T) {
10141015
})
10151016

10161017
mcpServer := NewMCPServer("test", "1.0.0", WithHooks(hooks), WithLogging())
1017-
testServer := NewTestStreamableHTTPServer(mcpServer)
1018+
testServer := NewTestStreamableHTTPServer(mcpServer, WithStateful(true))
10181019
defer testServer.Close()
10191020

10201021
// obtain a valid session ID first
@@ -1404,7 +1405,7 @@ func TestStreamableHTTP_SessionValidation(t *testing.T) {
14041405
server := NewTestStreamableHTTPServer(mcpServer)
14051406
defer server.Close()
14061407

1407-
t.Run("Reject tool call with fake session ID", func(t *testing.T) {
1408+
t.Run("Accept tool call with properly formatted session ID", func(t *testing.T) {
14081409
toolCallRequest := map[string]any{
14091410
"jsonrpc": "2.0",
14101411
"id": 1,
@@ -1425,13 +1426,29 @@ func TestStreamableHTTP_SessionValidation(t *testing.T) {
14251426
}
14261427
defer resp.Body.Close()
14271428

1428-
if resp.StatusCode != http.StatusBadRequest {
1429-
t.Errorf("Expected status 400, got %d", resp.StatusCode)
1429+
if resp.StatusCode != http.StatusOK {
1430+
t.Errorf("Expected status 200, got %d", resp.StatusCode)
14301431
}
14311432

14321433
body, _ := io.ReadAll(resp.Body)
1433-
if !strings.Contains(string(body), "Invalid session ID") {
1434-
t.Errorf("Expected 'Invalid session ID' error, got: %s", string(body))
1434+
var response map[string]any
1435+
if err := json.Unmarshal(body, &response); err != nil {
1436+
t.Fatalf("Failed to unmarshal response: %v", err)
1437+
}
1438+
1439+
if result, ok := response["result"].(map[string]any); ok {
1440+
if content, ok := result["content"].([]any); ok && len(content) > 0 {
1441+
if textContent, ok := content[0].(map[string]any); ok {
1442+
if text, ok := textContent["text"].(string); ok {
1443+
// Should be a valid timestamp response
1444+
if text == "" {
1445+
t.Error("Expected non-empty timestamp response")
1446+
}
1447+
}
1448+
}
1449+
}
1450+
} else {
1451+
t.Errorf("Expected result in response, got: %s", string(body))
14351452
}
14361453
})
14371454

@@ -1508,22 +1525,45 @@ func TestStreamableHTTP_SessionValidation(t *testing.T) {
15081525
}
15091526
})
15101527

1511-
t.Run("Reject tool call with terminated session ID", func(t *testing.T) {
1528+
t.Run("Reject tool call with terminated session ID (stateful mode)", func(t *testing.T) {
1529+
mcpServer := NewMCPServer("test-server", "1.0.0")
1530+
// Use explicit stateful mode for this test since termination requires local tracking
1531+
server := NewTestStreamableHTTPServer(mcpServer, WithStateful(true))
1532+
defer server.Close()
1533+
1534+
// First, initialize a session
1535+
initRequest := map[string]any{
1536+
"jsonrpc": "2.0",
1537+
"id": 1,
1538+
"method": "initialize",
1539+
"params": map[string]any{
1540+
"protocolVersion": "2025-03-26",
1541+
"capabilities": map[string]any{
1542+
"tools": map[string]any{},
1543+
},
1544+
"clientInfo": map[string]any{
1545+
"name": "test-client",
1546+
"version": "1.0.0",
1547+
},
1548+
},
1549+
}
1550+
15121551
jsonBody, _ := json.Marshal(initRequest)
15131552
req, _ := http.NewRequest(http.MethodPost, server.URL, bytes.NewBuffer(jsonBody))
15141553
req.Header.Set("Content-Type", "application/json")
15151554

15161555
resp, err := server.Client().Do(req)
15171556
if err != nil {
1518-
t.Fatalf("Failed to initialize: %v", err)
1557+
t.Fatalf("Failed to initialize session: %v", err)
15191558
}
1520-
resp.Body.Close()
15211559

15221560
sessionID := resp.Header.Get(HeaderKeySessionID)
15231561
if sessionID == "" {
15241562
t.Fatal("Expected session ID in response header")
15251563
}
1564+
resp.Body.Close()
15261565

1566+
// Now terminate the session
15271567
req, _ = http.NewRequest(http.MethodDelete, server.URL, nil)
15281568
req.Header.Set(HeaderKeySessionID, sessionID)
15291569

@@ -1780,13 +1820,19 @@ func TestDefaultSessionIdManagerResolver(t *testing.T) {
17801820
t.Error("Expected resolver to return a non-nil manager")
17811821
}
17821822

1783-
// Test that the resolved manager works (generates valid session IDs)
1823+
// Test that the resolved manager works (stateless behavior)
17841824
sessionID := resolved.Generate()
1785-
if sessionID == "" {
1786-
t.Error("Expected default manager to generate non-empty session ID")
1825+
if sessionID != "" {
1826+
t.Error("Expected stateless manager to generate empty session ID")
17871827
}
1788-
if !strings.HasPrefix(sessionID, idPrefix) {
1789-
t.Error("Expected default manager to generate session ID with correct prefix")
1828+
1829+
// Test that validation accepts any session ID (stateless behavior)
1830+
isTerminated, err := resolved.Validate("any-session-id")
1831+
if err != nil {
1832+
t.Errorf("Expected stateless manager to accept any session ID, got error: %v", err)
1833+
}
1834+
if isTerminated {
1835+
t.Error("Expected stateless manager to not terminate sessions")
17901836
}
17911837
})
17921838
}
@@ -1865,17 +1911,17 @@ func TestSessionIdManagerResolver_Integration(t *testing.T) {
18651911

18661912
server := NewStreamableHTTPServer(mcpServer, WithStateLess(false))
18671913

1868-
// Test that the default manager is still used (InsecureStatefulSessionIdManager)
1914+
// Test that the default manager is still used (StatelessGeneratingSessionIdManager)
18691915
req, _ := http.NewRequest("POST", "/test", nil)
18701916
resolved := server.sessionIdManagerResolver.ResolveSessionIdManager(req)
18711917

1872-
// Verify it's NOT a stateless manager
1918+
// Verify it's a generating manager (default behavior)
18731919
sessionID := resolved.Generate()
18741920
if sessionID == "" {
1875-
t.Error("Expected stateful manager when WithStateLess(false)")
1921+
t.Error("Expected generating manager to generate session ID by default")
18761922
}
18771923
if !strings.HasPrefix(sessionID, idPrefix) {
1878-
t.Error("Expected stateful session ID format")
1924+
t.Error("Expected generating manager to generate session ID with correct prefix")
18791925
}
18801926
})
18811927

@@ -1929,7 +1975,7 @@ func TestSessionIdManagerResolver_Integration(t *testing.T) {
19291975
t.Run("WithSessionIdManagerResolver handles nil resolver defensively", func(t *testing.T) {
19301976
mcpServer := NewMCPServer("test-server", "1.0.0")
19311977

1932-
// This should not panic and should fall back to default behavior
1978+
// This should not panic and should fall back to StatelessSessionIdManager (safe default)
19331979
server := NewStreamableHTTPServer(mcpServer, WithSessionIdManagerResolver(nil))
19341980

19351981
req, _ := http.NewRequest("POST", "/test", nil)
@@ -1938,20 +1984,17 @@ func TestSessionIdManagerResolver_Integration(t *testing.T) {
19381984
t.Error("Expected nil resolver to be replaced with default")
19391985
}
19401986

1941-
// Test that the resolved manager works (should be default stateful manager)
1987+
// Test that the resolved manager works (should be default stateless manager)
19421988
sessionID := resolved.Generate()
1943-
if sessionID == "" {
1944-
t.Error("Expected default manager to generate non-empty session ID")
1945-
}
1946-
if !strings.HasPrefix(sessionID, idPrefix) {
1947-
t.Error("Expected default manager to generate session ID with correct prefix")
1989+
if sessionID != "" {
1990+
t.Error("Expected default stateless manager to generate empty session ID")
19481991
}
19491992
})
19501993

19511994
t.Run("WithSessionIdManager handles nil manager defensively", func(t *testing.T) {
19521995
mcpServer := NewMCPServer("test-server", "1.0.0")
19531996

1954-
// This should not panic and should fall back to default behavior
1997+
// This should not panic and should fall back to StatelessSessionIdManager (safe default)
19551998
server := NewStreamableHTTPServer(mcpServer, WithSessionIdManager(nil))
19561999

19572000
req, _ := http.NewRequest("POST", "/test", nil)
@@ -1960,20 +2003,17 @@ func TestSessionIdManagerResolver_Integration(t *testing.T) {
19602003
t.Error("Expected nil manager to be replaced with default")
19612004
}
19622005

1963-
// Test that the resolved manager works (should be default stateful manager)
2006+
// Test that the resolved manager works (should be default stateless manager)
19642007
sessionID := resolved.Generate()
1965-
if sessionID == "" {
1966-
t.Error("Expected default manager to generate non-empty session ID")
1967-
}
1968-
if !strings.HasPrefix(sessionID, idPrefix) {
1969-
t.Error("Expected default manager to generate session ID with correct prefix")
2008+
if sessionID != "" {
2009+
t.Error("Expected default stateless manager to generate empty session ID")
19702010
}
19712011
})
19722012

19732013
t.Run("Multiple nil options fall back safely", func(t *testing.T) {
19742014
mcpServer := NewMCPServer("test-server", "1.0.0")
19752015

1976-
// Chain multiple nil options - last one should win with safe fallback
2016+
// Chain multiple nil options - last one should win with StatelessSessionIdManager fallback
19772017
server := NewStreamableHTTPServer(mcpServer,
19782018
WithSessionIdManager(nil),
19792019
WithSessionIdManagerResolver(nil),
@@ -1985,10 +2025,10 @@ func TestSessionIdManagerResolver_Integration(t *testing.T) {
19852025
t.Error("Expected chained nil options to fall back safely")
19862026
}
19872027

1988-
// Verify it generates valid session IDs
2028+
// Verify it uses stateless behavior (default)
19892029
sessionID := resolved.Generate()
1990-
if sessionID == "" {
1991-
t.Error("Expected fallback manager to generate non-empty session ID")
2030+
if sessionID != "" {
2031+
t.Error("Expected fallback stateless manager to generate empty session ID")
19922032
}
19932033
})
19942034

@@ -2021,6 +2061,28 @@ func TestSessionIdManagerResolver_Integration(t *testing.T) {
20212061
}
20222062
_ = resp.Body.Close()
20232063
})
2064+
2065+
t.Run("WithStateful enables stateful manager", func(t *testing.T) {
2066+
mcpServer := NewMCPServer("test-server", "1.0.0")
2067+
server := NewStreamableHTTPServer(mcpServer, WithStateful(true))
2068+
2069+
req, _ := http.NewRequest("POST", "/test", nil)
2070+
resolved := server.sessionIdManagerResolver.ResolveSessionIdManager(req)
2071+
2072+
sessionID := resolved.Generate()
2073+
if sessionID == "" {
2074+
t.Error("Expected stateful manager to generate session ID")
2075+
}
2076+
if !strings.HasPrefix(sessionID, idPrefix) {
2077+
t.Error("Expected stateful session ID format")
2078+
}
2079+
2080+
// Test that stateful manager validates session existence (unlike default)
2081+
_, err := resolved.Validate("unknown-session-id")
2082+
if err == nil {
2083+
t.Error("Expected stateful manager to reject unknown session ID")
2084+
}
2085+
})
20242086
}
20252087

20262088
func TestStreamableHTTP_SendNotificationToSpecificClient(t *testing.T) {
@@ -2039,7 +2101,7 @@ func TestStreamableHTTP_SendNotificationToSpecificClient(t *testing.T) {
20392101
})
20402102

20412103
mcpServer := NewMCPServer("test", "1.0.0", WithHooks(hooks))
2042-
testServer := NewTestStreamableHTTPServer(mcpServer)
2104+
testServer := NewTestStreamableHTTPServer(mcpServer, WithStateful(true))
20432105
defer testServer.Close()
20442106

20452107
// Send initialize request to register session
@@ -2110,7 +2172,7 @@ func TestStreamableHTTP_SendNotificationToSpecificClient(t *testing.T) {
21102172
return mcp.NewToolResultText("notification sent"), nil
21112173
})
21122174

2113-
testServer := NewTestStreamableHTTPServer(mcpServer)
2175+
testServer := NewTestStreamableHTTPServer(mcpServer, WithStateful(true))
21142176
defer testServer.Close()
21152177

21162178
// Initialize session

0 commit comments

Comments
 (0)