From 9ba7c9ec7a20bbfc8329d8d72d64351c00a96996 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:08:47 +0000 Subject: [PATCH 1/2] fix(coderd/x/chatd/mcpclient): enforce MCP connect budget and unblock session cleanup A black-holed MCP server (TCP accepted, no response, or SYNs silently dropped) stalled every chat generation step for about two minutes: go-sdk v1.7.0 detaches the context inside StreamableClientTransport.Connect, so the nominal 10s connect budget did not hold (observed 12s for a 2s deadline in tests, and ~125s kernel TCP timeouts in production), and step-end session cleanup blocked synchronously on the same detached requests. - Enforce the connect budget externally: run Connect+ListTools in a goroutine and select on the budget context. On timeout, abandon the goroutine and leave a reaper that drains its late result and closes any session that still materialized. - Stop using bare http.DefaultTransport: clone it and bound dials at 5s and response headers at 60s (toolCallTimeout, not connectTimeout, so slow JSON-response tools within the tool-call budget are not killed; SSE streams only need headers within the bound). http.Client.Timeout stays unset to keep SSE alive. - Close sessions in detached goroutines during cleanup so a server that wedges mid-turn cannot stall the generation loop at step boundaries. --- coderd/x/chatd/mcpclient/export_test.go | 27 ++ coderd/x/chatd/mcpclient/mcpclient.go | 106 ++++++-- .../chatd/mcpclient/mcpclient_connect_test.go | 241 ++++++++++++++++++ coderd/x/chatd/mcpclient/mcphttpclient.go | 63 ++++- .../mcpclient/mcphttpclient_internal_test.go | 27 ++ 5 files changed, 435 insertions(+), 29 deletions(-) create mode 100644 coderd/x/chatd/mcpclient/mcpclient_connect_test.go create mode 100644 coderd/x/chatd/mcpclient/mcphttpclient_internal_test.go diff --git a/coderd/x/chatd/mcpclient/export_test.go b/coderd/x/chatd/mcpclient/export_test.go index 50d350aba24..3bfc1ae5065 100644 --- a/coderd/x/chatd/mcpclient/export_test.go +++ b/coderd/x/chatd/mcpclient/export_test.go @@ -1,9 +1,36 @@ package mcpclient +import ( + "context" + "time" + + "charm.land/fantasy" + "github.com/google/uuid" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/database" +) + // ConvertCallResultForTest exposes convertCallResult for external // tests. var ConvertCallResultForTest = convertCallResult +// ConnectAllForTest exposes connectAll with an injectable connect +// timeout and a reaperDone hook that fires after an abandoned +// connect goroutine has been drained and its late session closed. +func ConnectAllForTest( + ctx context.Context, + logger slog.Logger, + configs []database.MCPServerConfig, + timeout time.Duration, + reaperDone func(), +) ([]fantasy.AgentTool, func()) { + return connectAllWithHooks( + ctx, logger, configs, nil, uuid.Nil, nil, nil, + timeout, connectHooks{reaperDone: reaperDone}, + ) +} + // BuildAuthHeadersForTest exposes buildAuthHeaders for external // tests. var BuildAuthHeadersForTest = buildAuthHeaders diff --git a/coderd/x/chatd/mcpclient/mcpclient.go b/coderd/x/chatd/mcpclient/mcpclient.go index 4f2ffe9b638..44326084fbd 100644 --- a/coderd/x/chatd/mcpclient/mcpclient.go +++ b/coderd/x/chatd/mcpclient/mcpclient.go @@ -84,6 +84,32 @@ func ConnectAll( userID uuid.UUID, oidcSrc UserOIDCTokenSource, coderHeaders map[string]string, +) ([]fantasy.AgentTool, func()) { + return connectAllWithHooks( + ctx, logger, configs, tokens, userID, oidcSrc, coderHeaders, + connectTimeout, connectHooks{}, + ) +} + +// connectHooks carries test-only instrumentation for connect +// internals. The zero value is used in production. +type connectHooks struct { + // reaperDone, when non-nil, is called after an abandoned + // connect goroutine's late result has been drained and any + // late session closed. + reaperDone func() +} + +func connectAllWithHooks( + ctx context.Context, + logger slog.Logger, + configs []database.MCPServerConfig, + tokens []database.MCPServerUserToken, + userID uuid.UUID, + oidcSrc UserOIDCTokenSource, + coderHeaders map[string]string, + timeout time.Duration, + hooks connectHooks, ) ([]fantasy.AgentTool, func()) { // Index tokens by server config ID so auth header // construction is O(1) per server. @@ -101,14 +127,20 @@ func ConnectAll( ) // Build cleanup eagerly so it always closes any sessions - // that connected, even if a later connection fails. + // that connected, even if a later connection fails. Each + // close runs in a detached goroutine: the sessions are + // discarded either way, and Close on a server that stopped + // responding mid-turn can block until the transport abandons + // the connection (the SDK detaches the request context), which + // must not stall the generation loop at step boundaries. cleanup := func() { mu.Lock() - defer mu.Unlock() - for _, s := range sessions { - _ = s.Close() - } + toClose := sessions sessions = nil + mu.Unlock() + for _, s := range toClose { + go func() { _ = s.Close() }() + } } var eg errgroup.Group @@ -120,6 +152,7 @@ func ConnectAll( eg.Go(func() error { serverTools, session, connectErr := connectOne( ctx, logger, cfg, tokensByConfigID, userID, oidcSrc, coderHeaders, + timeout, hooks, ) if connectErr != nil { logger.Warn(ctx, @@ -211,6 +244,8 @@ func connectOne( userID uuid.UUID, oidcSrc UserOIDCTokenSource, coderHeaders map[string]string, + timeout time.Duration, + hooks connectHooks, ) ([]fantasy.AgentTool, *mcp.ClientSession, error) { headers := buildAuthHeaders(ctx, logger, cfg, tokensByConfigID, userID, oidcSrc) @@ -250,21 +285,60 @@ func connectOne( // The timeout covers the entire connect+list sequence, not // each phase individually. The SDK negotiates the protocol // version during Connect; the session outlives connectCtx. - connectCtx, cancel := context.WithTimeout( - ctx, connectTimeout, - ) + connectCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - session, err := mcpClient.Connect(connectCtx, tr, nil) - if err != nil { - return nil, nil, xerrors.Errorf("connect: %w", err) + // Run the connect+list sequence in a goroutine and enforce the + // budget externally. The SDK's streamable transport detaches + // the context after starting HTTP requests, and its error-path + // session.Close blocks on those detached requests, so a + // black-holed server can block Connect far past connectCtx's + // deadline. The select below guarantees the caller gets an + // answer within the budget regardless. + type connectResult struct { + session *mcp.ClientSession + tools *mcp.ListToolsResult + err error + } + resCh := make(chan connectResult, 1) + go func() { + session, err := mcpClient.Connect(connectCtx, tr, nil) + if err != nil { + resCh <- connectResult{err: xerrors.Errorf("connect: %w", err)} + return + } + toolsResult, err := session.ListTools(connectCtx, nil) + if err != nil { + _ = session.Close() + resCh <- connectResult{err: xerrors.Errorf("list tools: %w", err)} + return + } + resCh <- connectResult{session: session, tools: toolsResult} + }() + + var res connectResult + select { + case res = <-resCh: + case <-connectCtx.Done(): + // Abandon the wedged goroutine; it exits once the + // transport's dial or response-header timeout fires. The + // reaper drains its late result and closes any session + // that still materialized so nothing leaks. It must not + // hold locks or block the caller. + go func() { + if late := <-resCh; late.session != nil { + _ = late.session.Close() + } + if hooks.reaperDone != nil { + hooks.reaperDone() + } + }() + return nil, nil, xerrors.Errorf("connect: %w", connectCtx.Err()) } - - toolsResult, err := session.ListTools(connectCtx, nil) - if err != nil { - _ = session.Close() - return nil, nil, xerrors.Errorf("list tools: %w", err) + if res.err != nil { + return nil, nil, res.err } + session, toolsResult := res.session, res.tools var tools []fantasy.AgentTool for _, mcpTool := range toolsResult.Tools { diff --git a/coderd/x/chatd/mcpclient/mcpclient_connect_test.go b/coderd/x/chatd/mcpclient/mcpclient_connect_test.go new file mode 100644 index 00000000000..566850cca58 --- /dev/null +++ b/coderd/x/chatd/mcpclient/mcpclient_connect_test.go @@ -0,0 +1,241 @@ +package mcpclient_test + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/mcpclient" +) + +// blackHoleListener accepts TCP connections and never responds, +// simulating a server (or an edge in front of it) that silently +// drops requests. Returned connections are tracked so the test can +// terminate them. +type blackHoleListener struct { + ln net.Listener + + mu sync.Mutex + conns []net.Conn +} + +func newBlackHoleListener(t *testing.T) *blackHoleListener { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + bh := &blackHoleListener{ln: ln} + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + bh.mu.Lock() + bh.conns = append(bh.conns, conn) + bh.mu.Unlock() + } + }() + t.Cleanup(bh.close) + return bh +} + +func (b *blackHoleListener) close() { + _ = b.ln.Close() + b.mu.Lock() + defer b.mu.Unlock() + for _, c := range b.conns { + _ = c.Close() + } + b.conns = nil +} + +func (b *blackHoleListener) url() string { + return "http://" + b.ln.Addr().String() +} + +// TestConnectAll_BlackHoledServerBudget is the acceptance test for +// the connect budget: one black-holed server must not delay turn +// preparation beyond the budget, and healthy servers' tools must +// still be discovered. Without external budget enforcement the SDK +// blocks several times past the context deadline (observed: 12s +// for a 2s deadline) because its transport detaches the context. +func TestConnectAll_BlackHoledServerBudget(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + bh := newBlackHoleListener(t) + healthy := newTestMCPServer(t, echoTool()) + + reaperDone := make(chan struct{}, 2) + timeout := 1 * time.Second + + start := time.Now() + tools, cleanup := mcpclient.ConnectAllForTest(ctx, logger, + []database.MCPServerConfig{ + makeConfig("blackhole", bh.url()), + makeConfig("healthy", healthy.URL), + }, + timeout, + func() { reaperDone <- struct{}{} }, + ) + elapsed := time.Since(start) + t.Cleanup(cleanup) + + // The budget must hold: well under the SDK's unbounded + // behavior (6x the deadline), with margin for slow CI. + require.Less(t, elapsed, 4*timeout, + "ConnectAll took %s, budget was %s", elapsed, timeout) + require.Equal(t, []string{"healthy__echo"}, toolNames(tools)) + + // Terminating the black-holed connections unblocks the + // abandoned connect goroutine; the reaper must then drain its + // result and exit. + bh.close() + select { + case <-reaperDone: + case <-time.After(30 * time.Second): + t.Fatal("reaper did not exit after black-holed connections were closed") + } +} + +// TestConnectAll_SlowServerStillConnects proves that a server that +// is slow but within the budget still connects and serves tools. +func TestConnectAll_SlowServerStillConnects(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + srv := mcp.NewServer(&mcp.Implementation{Name: "slow", Version: "1.0.0"}, nil) + tool := echoTool() + srv.AddTool(tool.tool, tool.handler) + handler := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return srv }, + &mcp.StreamableHTTPOptions{Stateless: true}, + ) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(300 * time.Millisecond) + handler.ServeHTTP(w, r) + })) + t.Cleanup(ts.Close) + + cfg := makeConfig("slow", ts.URL) + tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + t.Cleanup(cleanup) + + require.Equal(t, []string{"slow__echo"}, toolNames(tools)) +} + +// TestConnectAll_LateServerReaped proves that when a server only +// responds after the budget expired, ConnectAll has long returned +// and the abandoned connect goroutine's late result is drained by +// the reaper so nothing leaks. +func TestConnectAll_LateServerReaped(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + srv := mcp.NewServer(&mcp.Implementation{Name: "late", Version: "1.0.0"}, nil) + tool := echoTool() + srv.AddTool(tool.tool, tool.handler) + handler := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return srv }, + &mcp.StreamableHTTPOptions{Stateless: true}, + ) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(2 * time.Second) + handler.ServeHTTP(w, r) + })) + t.Cleanup(ts.Close) + + reaperDone := make(chan struct{}, 1) + timeout := 500 * time.Millisecond + + start := time.Now() + tools, cleanup := mcpclient.ConnectAllForTest(ctx, logger, + []database.MCPServerConfig{makeConfig("late", ts.URL)}, + timeout, + func() { reaperDone <- struct{}{} }, + ) + elapsed := time.Since(start) + t.Cleanup(cleanup) + + require.Less(t, elapsed, 4*timeout, + "ConnectAll took %s, budget was %s", elapsed, timeout) + require.Empty(t, tools) + + select { + case <-reaperDone: + case <-time.After(30 * time.Second): + t.Fatal("reaper did not exit after the late server responded") + } +} + +// TestConnectAll_CleanupPromptWhenServerWedges proves that the +// cleanup function returns promptly even when a connected session's +// server has stopped responding, so a wedged server cannot stall +// the generation loop at step boundaries. The session teardown +// DELETE is held server-side while cleanup must already have +// returned. +func TestConnectAll_CleanupPromptWhenServerWedges(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + srv := mcp.NewServer(&mcp.Implementation{Name: "wedge", Version: "1.0.0"}, nil) + tool := echoTool() + srv.AddTool(tool.tool, tool.handler) + // Stateful handler so closing the session sends a DELETE. + handler := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return srv }, nil, + ) + + var deleteArrived atomic.Bool + releaseDelete := make(chan struct{}) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + deleteArrived.Store(true) + select { + case <-releaseDelete: + case <-r.Context().Done(): + } + } + handler.ServeHTTP(w, r) + })) + t.Cleanup(ts.Close) + + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseDelete) }) } + // Registered after ts.Close so it runs before it, letting the + // wedged DELETE finish and the session unwind before the + // server waits for outstanding requests. + t.Cleanup(release) + + cfg := makeConfig("wedge", ts.URL) + tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + require.Equal(t, []string{"wedge__echo"}, toolNames(tools)) + + start := time.Now() + cleanup() + elapsed := time.Since(start) + require.Less(t, elapsed, 1*time.Second, + "cleanup took %s with a wedged server", elapsed) + + // The teardown must still happen in the background: the wedged + // DELETE arrives even though cleanup already returned. + require.Eventually(t, deleteArrived.Load, + 10*time.Second, 10*time.Millisecond, + "session close DELETE never reached the server") + release() +} diff --git a/coderd/x/chatd/mcpclient/mcphttpclient.go b/coderd/x/chatd/mcpclient/mcphttpclient.go index d30f248dc79..e149aec86da 100644 --- a/coderd/x/chatd/mcpclient/mcphttpclient.go +++ b/coderd/x/chatd/mcpclient/mcphttpclient.go @@ -2,11 +2,55 @@ package mcpclient import ( "flag" + "net" "net/http" + "time" ) +// dialTimeout bounds TCP connection establishment to an MCP +// server. Without it, a server that silently drops SYNs (for +// example an edge mitigation black-holing this deployment's +// egress IPs) blocks the dial until the kernel gives up +// retransmitting, roughly two minutes on Linux. +const dialTimeout = 5 * time.Second + +// responseHeaderTimeout bounds how long a server may take to send +// response headers once a request is written. It matches +// toolCallTimeout rather than connectTimeout because the same +// client serves tool-call POSTs, and a JSON-response MCP server +// sends no headers until the tool finishes, so a lower value would +// kill legitimate slow tools that fit the tool-call budget. +// Long-lived SSE streams are unaffected; only their headers must +// arrive within this window. http.Client.Timeout is deliberately +// unset because it would cap the stream body too. +const responseHeaderTimeout = toolCallTimeout + +// mcpSharedTransport is the transport for all production MCP +// connections. MCP traffic must not use http.DefaultTransport +// directly: the default has no dial or response-header bounds, so +// a black-holed server would hold connections for minutes. +var mcpSharedTransport = newMCPTransport() + +// newMCPTransport clones http.DefaultTransport when possible, +// preserving proxy and connection-pool settings, and tightens its +// failure timeouts so an unresponsive MCP server fails in seconds. +func newMCPTransport() *http.Transport { + tr, ok := http.DefaultTransport.(*http.Transport) + if ok { + tr = tr.Clone() + } else { + tr = &http.Transport{Proxy: http.ProxyFromEnvironment} + } + tr.DialContext = (&net.Dialer{ + Timeout: dialTimeout, + KeepAlive: 30 * time.Second, + }).DialContext + tr.ResponseHeaderTimeout = responseHeaderTimeout + return tr +} + func httpClientWithHeaders(headers map[string]string) *http.Client { - base := http.DefaultTransport + var base http.RoundTripper = mcpSharedTransport if isolated := mcpHTTPClient(); isolated != nil { base = isolated.Transport } @@ -33,20 +77,13 @@ func (h *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error } // mcpHTTPClient returns an isolated *http.Client when running -// inside tests, or nil for production. During tests, -// httptest.Server.Close() calls -// http.DefaultTransport.CloseIdleConnections(), which disrupts -// any MCP client sharing that transport. When DefaultTransport -// is a *http.Transport it is cloned; otherwise a minimal -// transport with ProxyFromEnvironment is created as a fallback. +// inside tests, or nil for production. During tests each client +// gets a fresh transport so closed httptest servers cannot leave +// stale pooled connections behind for later tests that reuse the +// same address. func mcpHTTPClient() *http.Client { if flag.Lookup("test.v") == nil { return nil } - if dt, ok := http.DefaultTransport.(*http.Transport); ok { - return &http.Client{Transport: dt.Clone()} - } - return &http.Client{Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - }} + return &http.Client{Transport: newMCPTransport()} } diff --git a/coderd/x/chatd/mcpclient/mcphttpclient_internal_test.go b/coderd/x/chatd/mcpclient/mcphttpclient_internal_test.go new file mode 100644 index 00000000000..9c577511114 --- /dev/null +++ b/coderd/x/chatd/mcpclient/mcphttpclient_internal_test.go @@ -0,0 +1,27 @@ +package mcpclient + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestMCPTransportTimeouts guards the transport hardening: MCP +// traffic must never ride a transport without dial and +// response-header bounds, or a black-holed server holds +// connections until the kernel gives up (about two minutes). +func TestMCPTransportTimeouts(t *testing.T) { + t.Parallel() + + shared := mcpSharedTransport + require.NotNil(t, shared.DialContext) + require.Equal(t, responseHeaderTimeout, shared.ResponseHeaderTimeout) + // The response-header bound must not undercut the tool-call + // budget, or slow JSON-response tools within budget would be + // killed at the HTTP layer. + require.GreaterOrEqual(t, responseHeaderTimeout, toolCallTimeout) + + isolated := mcpHTTPClient() + require.NotNil(t, isolated, "must be isolated under test") + require.NotSame(t, shared, isolated.Transport) +} From 7d60361ebd66a5bab7bdbe862b9b8a9de8194543 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:23:48 +0000 Subject: [PATCH 2/2] fix(coderd/x/chatd/mcpclient): close discarded no-tools sessions off the caller path --- coderd/x/chatd/mcpclient/mcpclient.go | 12 ++++- .../chatd/mcpclient/mcpclient_connect_test.go | 51 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/coderd/x/chatd/mcpclient/mcpclient.go b/coderd/x/chatd/mcpclient/mcpclient.go index 44326084fbd..e54e5a2d5ff 100644 --- a/coderd/x/chatd/mcpclient/mcpclient.go +++ b/coderd/x/chatd/mcpclient/mcpclient.go @@ -309,8 +309,12 @@ func connectOne( } toolsResult, err := session.ListTools(connectCtx, nil) if err != nil { - _ = session.Close() + // Deliver the result before closing: Close sends a + // DELETE on the SDK's detached context and can wedge, + // which would otherwise convert a fast ListTools + // failure into a budget timeout for the caller. resCh <- connectResult{err: xerrors.Errorf("list tools: %w", err)} + _ = session.Close() return } resCh <- connectResult{session: session, tools: toolsResult} @@ -360,7 +364,11 @@ func connectOne( } if len(tools) == 0 { - _ = session.Close() + // Close the discarded session asynchronously: Close sends + // a DELETE on the SDK's detached context, so a server that + // wedges after a successful connect would otherwise hold + // the caller far past the connect budget. + go func() { _ = session.Close() }() return nil, nil, nil } diff --git a/coderd/x/chatd/mcpclient/mcpclient_connect_test.go b/coderd/x/chatd/mcpclient/mcpclient_connect_test.go index 566850cca58..2152786bcad 100644 --- a/coderd/x/chatd/mcpclient/mcpclient_connect_test.go +++ b/coderd/x/chatd/mcpclient/mcpclient_connect_test.go @@ -239,3 +239,54 @@ func TestConnectAll_CleanupPromptWhenServerWedges(t *testing.T) { "session close DELETE never reached the server") release() } + +// TestConnectAll_NoToolsWedgedCloseWithinBudget proves that a +// server whose session yields no usable tools cannot stall +// ConnectAll past the connect budget when its teardown DELETE +// wedges. The discarded session must be closed off the caller's +// path, and the teardown must still happen in the background. +func TestConnectAll_NoToolsWedgedCloseWithinBudget(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + // Stateful handler with no registered tools so closing the + // discarded session sends a DELETE. + srv := mcp.NewServer(&mcp.Implementation{Name: "notools", Version: "1.0.0"}, nil) + handler := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return srv }, nil, + ) + + var deleteArrived atomic.Bool + releaseDelete := make(chan struct{}) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + deleteArrived.Store(true) + select { + case <-releaseDelete: + case <-r.Context().Done(): + } + } + handler.ServeHTTP(w, r) + })) + t.Cleanup(ts.Close) + + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseDelete) }) } + t.Cleanup(release) + + cfg := makeConfig("notools", ts.URL) + start := time.Now() + tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + elapsed := time.Since(start) + t.Cleanup(cleanup) + + require.Empty(t, tools) + require.Less(t, elapsed, 5*time.Second, + "ConnectAll took %s with a wedged no-tools teardown", elapsed) + + require.Eventually(t, deleteArrived.Load, + 10*time.Second, 10*time.Millisecond, + "discarded session close DELETE never reached the server") + release() +}