Skip to content

StreamableClientTransport: Connection poisoned by transient errors #683

Closed

Description

@Agustin-Jerusalinsky

Problem

The StreamableClientTransport permanently breaks after any transient error, making the connection unusable for all subsequent requests. This affects both stateful and stateless server modes.

Transient errors include:

  • Network timeouts (context deadline exceeded)
  • HTTP errors (503 Service Unavailable, 500 Internal Server Error, 502 Bad Gateway)
  • Network interruptions (connection refused, connection reset)

After a single transient error occurs, all subsequent requests fail with "client is closing", requiring a full reconnection. This affects production systems where temporary issues (server restarts, network glitches, load spikes) should be recoverable.


1. What did you do?

Created an MCP client using StreamableClientTransport and made three sequential tool calls, where the second call encounters a transient error (timeout, 503, network interruption, etc.):

httpClient := &http.Client{Timeout: /* some timeout */}
transport := &mcp.StreamableClientTransport{
    Endpoint:   serverURL,
    HTTPClient: httpClient,
}

session, _ := client.Connect(ctx, transport, nil)

// Call 1: Fast tool - should succeed
result1, err1 := session.CallTool(ctx, &mcp.CallToolParams{Name: "delay_tool"})

// Call 2: Encounters transient error (timeout, 503, etc.)
result2, err2 := session.CallTool(ctx, &mcp.CallToolParams{Name: "delay_tool"})

// Call 3: Fast tool - should succeed
result3, err3 := session.CallTool(ctx, &mcp.CallToolParams{Name: "delay_tool"})

2. What did you see?

Call #1: SUCCESS
Call #2: FAILED - calling "tools/call": sending "tools/call": Post "http://...": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
Call #3: FAILED - connection closed: calling "tools/call": client is closing: EOF
Call #4+: FAILED - connection closed: calling "tools/call": client is closing: EOF

After Call #2 encounters a transient error, all subsequent calls fail permanently with "client is closing: EOF" errors. The connection becomes unusable and must be recreated.

3. What did you expect to see?

Call #1: SUCCESS ✅
Call #2: FAILED - transient error (expected)
Call #3: SUCCESS ✅ (connection should survive the transient error)
Call #4+: SUCCESS ✅

The connection should remain healthy after transient errors. Only fatal errors (authentication failures, protocol errors, session termination) should require reconnection.

4. What version of the Go MCP SDK are you using?

Bug reproduced in:

  • main branch (commit 272e0cd)
  • v1.1.0 (latest release)
  • v1.0.0 (initial release)

The bug exists in all released versions of the SDK.

5. What version of Go are you using (go version)?

go version go1.23.3 darwin/arm64

Root Cause

The streamableClientConn.Write() method returns transient errors without wrapping them in jsonrpc2.ErrRejected. This causes the underlying jsonrpc2.Connection to set its writeErr flag permanently (in internal/jsonrpc2/conn.go:794-810), which is never cleared and blocks all future operations.

The jsonrpc2 package already has a mechanism to handle recoverable errors via ErrRejected (see conn.go:788-792), but the streamable transport doesn't use it.

After any transient error, the connection becomes unusable for all MCP operations: CallTool(), ListTools(), ListResources(), etc. The entire session must be recreated, even though the error was temporary and should have been recoverable.

Related Issues

Reproduction Test Code

Below is a minimal reproduction test:

Click to expand test code
package gosdk

import (
    "context"
    "fmt"
    "net/http"
    "net/http/httptest"
    "sync/atomic"
    "testing"
    "time"

    "github.com/modelcontextprotocol/go-sdk/mcp"
)

// testDelays defines the sleep duration for each call
var testDelays = []time.Duration{
    500 * time.Millisecond, // Call 1: fast, should succeed
    3 * time.Second,        // Call 2: slow, will timeout
    500 * time.Millisecond, // Call 3: fast, should succeed but fails due to bug
}

// createDelayTool creates an MCP tool that sleeps for configurable durations
func createDelayTool(callCount *atomic.Int32) (*mcp.Server, *mcp.Tool) {
    server := mcp.NewServer(&mcp.Implementation{
        Name:    "test-server",
        Version: "1.0.0",
    }, nil)

    tool := &mcp.Tool{
        Name:        "delay_tool",
        Description: "Tool with configurable delays for testing",
    }

    handler := func(ctx context.Context, req *mcp.CallToolRequest, args any) (*mcp.CallToolResult, any, error) {
        callNum := int(callCount.Add(1))
        delay := testDelays[0] // default
        if callNum <= len(testDelays) {
            delay = testDelays[callNum-1]
        }

        time.Sleep(delay)

        return &mcp.CallToolResult{
            Content: []mcp.Content{
                &mcp.TextContent{
                    Text: fmt.Sprintf("Call #%d completed", callNum),
                },
            },
        }, nil, nil
    }

    mcp.AddTool(server, tool, handler)
    return server, tool
}

// setupTestServer creates and starts an HTTP test server with MCP handler
func setupTestServer(t *testing.T, server *mcp.Server, stateless bool) *httptest.Server {
    var opts *mcp.StreamableHTTPOptions
    if stateless {
        opts = &mcp.StreamableHTTPOptions{Stateless: true}
    }

    handler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server {
        return server
    }, opts)

    httpServer := httptest.NewServer(handler)
    t.Cleanup(httpServer.Close)
    return httpServer
}

// createMCPSession creates and connects an MCP client session
func createMCPSession(t *testing.T, serverURL string, clientTimeout time.Duration) *mcp.ClientSession {
    ctx := context.Background()
    httpClient := &http.Client{Timeout: clientTimeout}

    client := mcp.NewClient(&mcp.Implementation{
        Name:    "test-client",
        Version: "1.0.0",
    }, nil)

    transport := &mcp.StreamableClientTransport{
        Endpoint:   serverURL,
        HTTPClient: httpClient,
    }

    session, err := client.Connect(ctx, transport, nil)
    if err != nil {
        t.Fatalf("Failed to connect: %v", err)
    }
    t.Cleanup(func() { session.Close() })

    return session
}

// callResult represents the result of a CallTool invocation
type callResult struct {
    num    int
    err    error
    result *mcp.CallToolResult
}

// performCallSequence executes three sequential tool calls and returns results
func performCallSequence(session *mcp.ClientSession) []callResult {
    results := make([]callResult, 3)

    for i := range 3 {
        ctx := context.Background() // Fresh context for each call
        result, err := session.CallTool(ctx, &mcp.CallToolParams{
            Name:      "delay_tool",
            Arguments: map[string]any{},
        })
        results[i] = callResult{num: i + 1, err: err, result: result}
    }

    return results
}

// reportResults logs the test results
func reportResults(t *testing.T, results []callResult) {
    t.Helper()

    for _, r := range results {
        if r.err != nil {
            t.Logf("Call #%d: FAILED - %v", r.num, r.err)
        } else {
            t.Logf("Call #%d: SUCCESS", r.num)
        }
    }

    // Expected behavior: Call 3 should succeed even after Call 2 timeout
    if results[2].err != nil {
        t.Errorf("Call #3 failed after transient error in Call #2")
    }
}

// TestTimeoutBugReproduction tests connection behavior after timeout in stateful mode.
// Expected: Call #3 should succeed even after Call #2 timeout.
func TestTimeoutBugReproduction(t *testing.T) {
    var callCount atomic.Int32
    server, _ := createDelayTool(&callCount)
    httpServer := setupTestServer(t, server, false) // stateful mode
    session := createMCPSession(t, httpServer.URL, 2*time.Second)

    results := performCallSequence(session)
    reportResults(t, results)
}

// TestTimeoutStateless tests connection behavior after timeout in stateless mode.
// Expected: Call #3 should succeed even after Call #2 timeout.
func TestTimeoutStateless(t *testing.T) {
    var callCount atomic.Int32
    server, _ := createDelayTool(&callCount)
    httpServer := setupTestServer(t, server, true) // stateless mode
    session := createMCPSession(t, httpServer.URL, 2*time.Second)

    results := performCallSequence(session)
    reportResults(t, results)
}

Activity

  1. added this to the v1.2.0 milestone on Dec 4, 2025
  2. findleyr commented on Dec 4, 2025

    @findleyr
    Contributor

    Yes, I agree we were overly aggressive in terminating the client connection, and the assessment is correct that we should wrap errors in ErrRejected. I've been thinking about this as well, recently.

    I think we've been surprised by how much usage our client transport has gotten, when our assumption was that the overwhelming of usage would be server-side. Therefore, we were more defensive in the client transport, erring on the side of failing the connection rather than allowing recovery. In principle, the failure to return a response to the server could corrupt the session, and we (really, I) preferred breaking the session over having a potentially incoherent session.

    Thank you for filing; we'll fix for v1.2.0.

  3. added
    help wantedContributions welcome from those familiar with codebase. Comment first to say you're working on it
    and removed
    help wantedContributions welcome from those familiar with codebase. Comment first to say you're working on it
    on Dec 4, 2025
  4. self-assigned this
    on Dec 4, 2025
  5. findleyr commented on Dec 5, 2025

    @findleyr
    Contributor

    Just a quick note after thinking about this more: I think we should actually get rid of ErrRejected and have a 'flaky' mode in the jsonrpc2 library, where delivery failure doesn't break the connection.

    Fundamentally, this is an artifact of our jsonrpc2 library growing out of a stdio-only context, and this particular behavior doesn't suit the semantics of the streamable transport. ErrRejected was just a patch.

  6. findleyr commented on Dec 22, 2025

    @findleyr
    Contributor

    This is partially addressed. I think there's more to do, so I'll leave this open and move to v1.3.0.

  7. modified the milestones: v1.2.0, v1.3.0 on Dec 22, 2025
  8. removed this from the v1.3.0 milestone on Jan 26, 2026
  9. added
    needs investigationStatus unclear, requires more work and discussion
    and removed on Feb 13, 2026
  10. added a commit that references this issue on Mar 9, 2026
    8f21ef7
  11. ravyg commented on Apr 8, 2026

    @ravyg
    Contributor

    Hi @findleyr — I hope it's okay to ask, is this still open for someone outside the team to pick up?

    After reading #723 and your follow-up comments, my understanding is the remaining piece is the handleSSE path: when a transient error happens mid-stream, the connection still gets poisoned the same way the POST path used to be. I'd be happy to send a small PR that mirrors the ErrRejected pattern from #723 if that's still the right shape.

    That said, I noticed you also mentioned a deeper direction — replacing ErrRejected with a "flaky mode" in the jsonrpc2 library. If that's the path you'd rather take, I'd be glad to take that on instead (or in addition) — happy to align with you on the design before writing any code so it lands the way you want it.

    Whichever direction you prefer, could you let me know? And if it's okay, please assign the issue to me so I don't accidentally collide with someone else picking it up.

  12. maciej-kisiel commented on Apr 9, 2026

    @maciej-kisiel
    Contributor

    Hi @ravyg,

    Robert is no longer an active maintainer of the repository. As for the task: I checked out the code and it looks like the original issue should not be applicable to SSE streams anymore, given that handleSSE is always accompanied by checkResponse that treats the errors as transient:

    // Transient server errors (502, 503, 504, 429) should not break the connection.

    Do you have any concrete errors that you're seeing that are not handled correctly?

  13. robfischer1 commented on Aug 1, 2026

    @robfischer1

    Another trigger for this, and one that makes the blast radius larger than "a transient error breaks later calls": a protocol-level 400 on a ping poisons the connection the same way, and it does so from a background caller rather than the application's own call path.

    Filed separately as #1130 — ClientSession.Ping cannot succeed on a 2026-07-28 session at all (it is the last Modern-eligible method that never calls injectRequestMeta, and the stateless path then fails session not found). The interaction with this issue is what turns that from a broken health check into an outage:

    stateless + bare ping        negotiated=2026-07-28  ping=ERR  listTools_after=ERR
          ping err: calling "ping": sending "ping": missing or invalid _meta field "io.modelcontextprotocol/protocolVersion": Bad Request
          poisoned: connection closed: calling "tools/list": client is closing: sending "ping": missing or invalid _meta field "io.modelcontextprotocol/protocolVersion": Bad Request
    

    Client and server are both this SDK; full reproducer in #1130. Note the second line: tools/list reports an error naming ping, a method it never issued.

    Two properties worth adding to the case here:

    1. The poisoning is retroactive from the caller's perspective. A liveness prober and the application share the pooled session, so an application call that was already in flight fails citing a ping it never made. The error text points at the wrong subsystem entirely, which is a meaningful diagnosis cost — we spent a while looking at the call path before finding the prober.
    2. A 400 is not "transient" in the sense this issue lists, but is treated identically. The listed triggers are timeouts, 5xx, and network faults, all of which plausibly warrant tearing down. A 4xx on one request is a statement about that request, not about the connection — the server is manifestly alive and answering. Collapsing the whole session on it means a single malformed request type can take down an otherwise healthy multiplexed connection indefinitely, since every retry re-sends the same malformed request.

    Observed in production on a gateway multiplexing ~30 upstreams over pooled sessions: the 30s liveness ping failed against every 2026-07-28 upstream, and unrelated in-flight application calls intermittently died with calling "tools/call": sending "ping": .... Fixing it required the caller to stop pinging v2 sessions entirely — but had a failed ping not closed the connection, the broken health check would have been merely useless rather than actively harmful.

  14. Ashish5380 commented on Sep 26, 2026

    @Ashish5380

    Been following this one. The remaining gap you flagged is the handleSSE path mid-stream recovery after #723; the ping-level 400 poisoning case from the thread is the real blast-radius multiplier. Happy to take a pass: implement a 'flaky mode' in the jsonrpc2 layer that retries non-application responses, add the regression tests for SSE interruption + ping 400, and verify the Stateless vs Stateful paths both recover. Go maintainer experience, comfortable with jsonrpc2. Posting a design note in the issue before touching code — want to confirm the flaky-mode shape matches what you had in mind.

  15. guglielmo-san commented on Oct 15, 2026

    @guglielmo-san
    Contributor

    Closing this issue as the overall decision is to avoid the closure of connection only for new protocol version (>=20260728)
    See PR #1353

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

needs investigationStatus unclear, requires more work and discussion

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions