diff --git a/mcp/streamable.go b/mcp/streamable.go index b761f9c..6ed5d3d 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2016,6 +2016,11 @@ const ( reconnectGrowFactor = 1.5 // reconnectMaxDelay caps the backoff delay, preventing it from growing indefinitely. reconnectMaxDelay = 30 * time.Second + // closeDeleteTimeout bounds the session-termination DELETE sent by Close. + // That request is best-effort: a peer that accepts the connection but never + // answers must not be able to hold teardown open until the TCP timeout. + // See issue #1183. + closeDeleteTimeout = 5 * time.Second ) var ( @@ -2767,7 +2772,9 @@ func (c *streamableClientConn) Close() error { // No session was established (e.g. the server is stateless), // so there is nothing to delete. } else { - req, err := http.NewRequestWithContext(c.ctx, http.MethodDelete, c.url, nil) + reqCtx, stop := context.WithTimeout(c.ctx, closeDeleteTimeout) + defer stop() + req, err := http.NewRequestWithContext(reqCtx, http.MethodDelete, c.url, nil) if err != nil { c.closeErr = err } else { diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 389f87e..64c4da5 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -281,6 +281,48 @@ func TestStreamableClientRedundantDelete(t *testing.T) { } } +// TestStreamableClientCloseUnresponsiveDelete checks that Close stays bounded +// when the peer accepts the session-termination DELETE but never answers it. +func TestStreamableClientCloseUnresponsiveDelete(t *testing.T) { + base := NewStreamableHTTPHandler(func(*http.Request) *Server { + return NewServer(testImpl, nil) + }, nil) + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + <-r.Context().Done() + return + } + base.ServeHTTP(w, r) + })) + t.Cleanup(func() { + // The DELETE handler only returns once its request is cancelled, so + // drop the connections before waiting for outstanding requests. + httpServer.CloseClientConnections() + httpServer.Close() + }) + + client := NewClient(testImpl, nil) + session, err := client.Connect(context.Background(), + &StreamableClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Fatalf("client.Connect() failed: %v", err) + } + if session.ID() == "" { + t.Fatal("empty session ID: Close would not send DELETE") + } + + done := make(chan struct{}) + go func() { + session.Close() + close(done) + }() + select { + case <-done: + case <-time.After(closeDeleteTimeout + 5*time.Second): + t.Fatal("Close() blocked on an unanswered DELETE") + } +} + func TestStreamableClientGETHandling(t *testing.T) { ctx := context.Background()