mcp: fix context propagation in StreamableClientTransport (#514)

## Problem
Context propagation was broken in `StreamableClientTransport` because:
1. `Connect()` used `context.Background()` instead of the parent context
2. `Close()` created a race condition where DELETE requests were
cancelled before completion

## Solution  
- Use parent context when creating the connection context in `Connect()`
- Reorder `Close()` operations to perform cleanup DELETE before
cancelling context

## Impact
- Request-scoped values (auth tokens, trace IDs) now propagate correctly
to background HTTP operations
- Eliminates race condition in cleanup operations  
- Maintains proper Go context semantics

Fixes #513
This commit is contained in:
Manuel Ibar 2025-09-23 13:27:39 -03:00 • committed by GitHub
commit 5d64d61974
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1310,8 +1310,9 @@ func textContent(t *testing.T, res *CallToolResult) string {
}
func TestTokenInfo(t *testing.T) {
defer func(b bool) { testAuth = b }(testAuth)
testAuth = true
oldAuth := testAuth.Load()
defer testAuth.Store(oldAuth)
testAuth.Store(true)
ctx := context.Background()
// Create a server with a tool that returns TokenInfo.
@ -1430,3 +1431,57 @@ func TestStreamableGET(t *testing.T) {
t.Errorf("GET with session ID: got status %d, want %d", got, want)
}
}
func TestStreamableClientContextPropagation(t *testing.T) {
type contextKey string
const testKey = contextKey("test-key")
const testValue = "test-value"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ctx2 := context.WithValue(ctx, testKey, testValue)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case "POST":
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Mcp-Session-Id", "test-session")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26","capabilities":{},"serverInfo":{"name":"test","version":"1.0"}}}`))
case "GET":
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
case "DELETE":
w.WriteHeader(http.StatusNoContent)
}
}))
defer server.Close()
transport := &StreamableClientTransport{Endpoint: server.URL}
conn, err := transport.Connect(ctx2)
if err != nil {
t.Fatalf("Connect failed: %v", err)
}
defer conn.Close()
streamableConn, ok := conn.(*streamableClientConn)
if !ok {
t.Fatalf("Expected *streamableClientConn, got %T", conn)
}
if got := streamableConn.ctx.Value(testKey); got != testValue {
t.Errorf("Context value not propagated: got %v, want %v", got, testValue)
}
if streamableConn.ctx.Done() == nil {
t.Error("Connection context is not cancellable")
}
cancel()
select {
case <-streamableConn.ctx.Done():
case <-time.After(100 * time.Millisecond):
t.Error("Connection context was not cancelled when parent was cancelled")
}
}