From d9f25e66a144dbf0b5673c7750b8e450f666ee43 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:33:48 +0000 Subject: [PATCH 1/8] feat(coderd/x/chatd): add MCP connect and generation-prep observability Chat turns that stall in MCP connects or preparation were invisible: the debug UI showed a silent gap before the first step and logs had no durations. - ConnectAll returns per-server ConnectSummary values (outcome, duration, tool count, redacted error), logs connect duration on failure, and warns on successful connects slower than 5s. - Connect summaries are seeded into the chat debug run summary (mcp_connect key) and rendered in the debug panel run card as a per-server list with outcome badges, durations, and errors. - prepareGeneration warns when preparation exceeds 30s. --- coderd/x/chatd/active_turn_debug.go | 10 ++ coderd/x/chatd/chatd.go | 6 + coderd/x/chatd/generation.go | 5 + coderd/x/chatd/generation_preparer.go | 13 ++- .../x/chatd/mcpclient/coder_headers_test.go | 12 +- coderd/x/chatd/mcpclient/export_test.go | 2 +- coderd/x/chatd/mcpclient/mcpclient.go | 110 +++++++++++++++--- .../chatd/mcpclient/mcpclient_connect_test.go | 25 +++- coderd/x/chatd/mcpclient/mcpclient_test.go | 76 ++++++------ .../DebugPanel/DebugPanel.stories.tsx | 70 +++++++++++ .../RightPanel/DebugPanel/DebugRunCard.tsx | 66 ++++++++++- .../DebugPanel/debugPanelUtils.test.ts | 44 +++++++ .../RightPanel/DebugPanel/debugPanelUtils.ts | 39 +++++++ 13 files changed, 412 insertions(+), 66 deletions(-) diff --git a/coderd/x/chatd/active_turn_debug.go b/coderd/x/chatd/active_turn_debug.go index 2fbd653a04130..d9c2721496d61 100644 --- a/coderd/x/chatd/active_turn_debug.go +++ b/coderd/x/chatd/active_turn_debug.go @@ -68,6 +68,16 @@ func (d *runnerDebugTurn) Ensure( seedSummary := chatdebug.SeedSummary( chatdebug.TruncateLabel(debug.TriggerLabel, chatdebug.MaxLabelLength), ) + // Seed per-server MCP connect outcomes so slow or failing + // servers appear in the run instead of as a silent gap before + // the first step. Seeded keys survive FinalizeRun's summary + // aggregation. + if len(debug.MCPConnectSummaries) > 0 { + if seedSummary == nil { + seedSummary = make(map[string]any, 1) + } + seedSummary["mcp_connect"] = debug.MCPConnectSummaries + } rootChatID := uuid.Nil if chat.RootChatID.Valid { rootChatID = chat.RootChatID.UUID diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index fbb3a22bf368f..1c3570f9360c5 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -84,6 +84,12 @@ const ( DefaultChatHeartbeatInterval = 30 * time.Second maxChatSteps = 1200 + // slowPrepareThreshold is the generation-preparation duration + // above which a warning is logged. Preparation runs before + // every generation step, so sustained slowness (workspace + // dials, MCP connects) taxes the whole turn. + slowPrepareThreshold = 30 * time.Second + // maxConcurrentRecordingUploads caps the number of recording // stop-and-store operations that can run concurrently. Each // slot buffers up to MaxRecordingSize + MaxThumbnailSize diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index 33eb7ff7c33f4..5843e822966d2 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatretry" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/chatd/chattool" + "github.com/coder/coder/v2/coderd/x/chatd/mcpclient" "github.com/coder/coder/v2/coderd/x/chatd/messagepartbuffer" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/x/agenthooks" @@ -95,6 +96,10 @@ type generationDebug struct { HistoryTipMessageID int64 TriggerLabel string ModelConfig database.ChatModelConfig + // MCPConnectSummaries carries per-server MCP connect outcomes + // from turn preparation into the debug run summary so slow or + // failing servers are visible in the debug UI. + MCPConnectSummaries []mcpclient.ConnectSummary } // generationOutcome describes a completed generation outcome. diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index bb79819a3f56a..b6f1d26a07dcc 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -76,6 +76,15 @@ func (server *Server) prepareGeneration( slog.F("owner_id", chat.OwnerID), ) + prepStart := server.clock.Now() + defer func() { + if prepDuration := server.clock.Since(prepStart); prepDuration >= slowPrepareThreshold { + logger.Warn(ctx, "slow generation preparation", + slog.F("duration", prepDuration), + ) + } + }() + var ( promptRows []database.ChatMessage mcpConfigs []database.MCPServerConfig @@ -263,6 +272,7 @@ func (server *Server) prepareGeneration( prompt []fantasy.Message instruction string mcpTools []fantasy.AgentTool + mcpSummaries []mcpclient.ConnectSummary mcpCleanup func() workspaceMCPTools []fantasy.AgentTool workspaceSkills []chattool.SkillMeta @@ -336,7 +346,7 @@ func (server *Server) prepareGeneration( logger.Warn(ctx, "failed to load MCP user tokens", slog.Error(tokenErr)) } mcpTokens = server.refreshExpiredMCPTokens(ctx, logger, mcpConnectConfigs, mcpTokens) - mcpTools, mcpCleanup = mcpclient.ConnectAll( + mcpTools, mcpSummaries, mcpCleanup = mcpclient.ConnectAll( ctx, logger, mcpConnectConfigs, @@ -652,6 +662,7 @@ func (server *Server) prepareGeneration( HistoryTipMessageID: historyTipMessageID, TriggerLabel: triggerLabel, ModelConfig: modelConfig, + MCPConnectSummaries: mcpSummaries, } } diff --git a/coderd/x/chatd/mcpclient/coder_headers_test.go b/coderd/x/chatd/mcpclient/coder_headers_test.go index a034c38bdb7f9..1835e10f732ff 100644 --- a/coderd/x/chatd/mcpclient/coder_headers_test.go +++ b/coderd/x/chatd/mcpclient/coder_headers_test.go @@ -63,7 +63,7 @@ func TestConnectAll_ForwardCoderHeaders_DefaultOff(t *testing.T) { chatprovider.HeaderCoderWorkspaceID: uuid.NewString(), } - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, coderHeaders, ) @@ -113,7 +113,7 @@ func TestConnectAll_ForwardCoderHeaders_Enabled(t *testing.T) { WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, }) - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, coderHeaders, ) @@ -156,7 +156,7 @@ func TestConnectAll_ForwardCoderHeaders_RootChat(t *testing.T) { OwnerID: ownerID, }) - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, coderHeaders, ) @@ -202,7 +202,7 @@ func TestConnectAll_ForwardCoderHeaders_WithAPIKeyAuth(t *testing.T) { OwnerID: ownerID, }) - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, coderHeaders, ) @@ -252,7 +252,7 @@ func TestConnectAll_ForwardCoderHeaders_WithOAuth2(t *testing.T) { chatprovider.HeaderCoderOwnerID: ownerID, } - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg}, []database.MCPServerUserToken{token}, @@ -304,7 +304,7 @@ func TestConnectAll_ForwardCoderHeaders_WithCustomHeaders(t *testing.T) { OwnerID: ownerID, }) - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, coderHeaders, ) diff --git a/coderd/x/chatd/mcpclient/export_test.go b/coderd/x/chatd/mcpclient/export_test.go index 3bfc1ae506507..d5ec3bb687111 100644 --- a/coderd/x/chatd/mcpclient/export_test.go +++ b/coderd/x/chatd/mcpclient/export_test.go @@ -24,7 +24,7 @@ func ConnectAllForTest( configs []database.MCPServerConfig, timeout time.Duration, reaperDone func(), -) ([]fantasy.AgentTool, func()) { +) ([]fantasy.AgentTool, []ConnectSummary, func()) { return connectAllWithHooks( ctx, logger, configs, nil, uuid.Nil, nil, nil, timeout, connectHooks{reaperDone: reaperDone}, diff --git a/coderd/x/chatd/mcpclient/mcpclient.go b/coderd/x/chatd/mcpclient/mcpclient.go index e54e5a2d5ffd7..f6538f9c07b48 100644 --- a/coderd/x/chatd/mcpclient/mcpclient.go +++ b/coderd/x/chatd/mcpclient/mcpclient.go @@ -58,6 +58,46 @@ const connectTimeout = 10 * time.Second // take before being canceled. const toolCallTimeout = 60 * time.Second +// slowConnectThreshold is the successful-connect duration above +// which a warning is logged. Connects happen on every generation +// step, so a consistently slow server taxes the whole chat even +// when it stays inside the connect budget. +const slowConnectThreshold = 5 * time.Second + +// ConnectOutcome classifies the result of one MCP server connect +// attempt for logs and chat debug runs. +type ConnectOutcome string + +const ( + // ConnectOutcomeConnected means tools were discovered and the + // session is live. + ConnectOutcomeConnected ConnectOutcome = "connected" + // ConnectOutcomeTimeout means the connect budget elapsed before + // the server completed the handshake and tool listing. + ConnectOutcomeTimeout ConnectOutcome = "timeout" + // ConnectOutcomeError means the handshake or tool listing + // failed. + ConnectOutcomeError ConnectOutcome = "error" + // ConnectOutcomeNoTools means the server connected but no tools + // survived allow/deny filtering, so the session was closed. + ConnectOutcomeNoTools ConnectOutcome = "no_tools" +) + +// ConnectSummary describes one MCP server connect attempt. It is +// logged and recorded into chat debug runs so slow or failing +// servers are visible instead of appearing as silent gaps in the +// turn timeline. +type ConnectSummary struct { + ConfigID uuid.UUID `json:"config_id"` + Slug string `json:"slug"` + Outcome ConnectOutcome `json:"outcome"` + DurationMS int64 `json:"duration_ms"` + ToolCount int `json:"tool_count,omitempty"` + // Error is the redacted connect error, present unless the + // outcome is connected or no_tools. + Error string `json:"error,omitempty"` +} + // UserOIDCTokenSource resolves the OIDC access token for the calling // user. Implementations attempt to refresh tokens that are expired // or close to expiring and MUST return ("", nil) when the user has @@ -74,8 +114,9 @@ type UserOIDCTokenSource interface { // their tools, and returns them as fantasy.AgentTool values. // Tools are sorted by their prefixed name so callers // receive a deterministic order. It skips servers that fail to -// connect and logs warnings. The returned cleanup function -// must be called to close all connections. +// connect and logs warnings; per-server outcomes are returned as +// ConnectSummary values sorted by slug. The returned cleanup +// function must be called to close all connections. func ConnectAll( ctx context.Context, logger slog.Logger, @@ -84,7 +125,7 @@ func ConnectAll( userID uuid.UUID, oidcSrc UserOIDCTokenSource, coderHeaders map[string]string, -) ([]fantasy.AgentTool, func()) { +) ([]fantasy.AgentTool, []ConnectSummary, func()) { return connectAllWithHooks( ctx, logger, configs, tokens, userID, oidcSrc, coderHeaders, connectTimeout, connectHooks{}, @@ -110,7 +151,7 @@ func connectAllWithHooks( coderHeaders map[string]string, timeout time.Duration, hooks connectHooks, -) ([]fantasy.AgentTool, func()) { +) ([]fantasy.AgentTool, []ConnectSummary, func()) { // Index tokens by server config ID so auth header // construction is O(1) per server. tokensByConfigID := make( @@ -121,9 +162,10 @@ func connectAllWithHooks( } var ( - mu sync.Mutex - sessions []*mcp.ClientSession - tools []fantasy.AgentTool + mu sync.Mutex + sessions []*mcp.ClientSession + tools []fantasy.AgentTool + summaries []ConnectSummary ) // Build cleanup eagerly so it always closes any sessions @@ -150,28 +192,59 @@ func connectAllWithHooks( } eg.Go(func() error { + start := time.Now() serverTools, session, connectErr := connectOne( ctx, logger, cfg, tokensByConfigID, userID, oidcSrc, coderHeaders, timeout, hooks, ) + duration := time.Since(start) + summary := ConnectSummary{ + ConfigID: cfg.ID, + Slug: cfg.Slug, + DurationMS: duration.Milliseconds(), + ToolCount: len(serverTools), + } + switch { + case connectErr != nil && errors.Is(connectErr, context.DeadlineExceeded): + summary.Outcome = ConnectOutcomeTimeout + summary.Error = redactErrorURL(connectErr) + case connectErr != nil: + summary.Outcome = ConnectOutcomeError + summary.Error = redactErrorURL(connectErr) + case len(serverTools) == 0: + summary.Outcome = ConnectOutcomeNoTools + default: + summary.Outcome = ConnectOutcomeConnected + } + if connectErr != nil { logger.Warn(ctx, "skipping MCP server due to connection failure", slog.F("server_slug", cfg.Slug), slog.F("server_url", RedactURL(cfg.Url)), + slog.F("duration", duration), slog.F("error", redactErrorURL(connectErr)), ) - // Connection failures are not propagated — the - // LLM simply won't have this server's tools. - return nil + } else if duration >= slowConnectThreshold { + logger.Warn(ctx, + "slow MCP server connect", + slog.F("server_slug", cfg.Slug), + slog.F("server_url", RedactURL(cfg.Url)), + slog.F("duration", duration), + ) } mu.Lock() - if session != nil { - sessions = append(sessions, session) + summaries = append(summaries, summary) + if connectErr == nil { + if session != nil { + sessions = append(sessions, session) + } + tools = append(tools, serverTools...) } - tools = append(tools, serverTools...) mu.Unlock() + // Connection failures are not propagated; the + // LLM simply won't have this server's tools. return nil }) } @@ -180,6 +253,15 @@ func connectAllWithHooks( // discarded. _ = eg.Wait() + // Sort summaries for deterministic ordering regardless of + // goroutine completion order. + slices.SortFunc(summaries, func(a, b ConnectSummary) int { + return cmp.Or( + cmp.Compare(a.Slug, b.Slug), + cmp.Compare(a.ConfigID.String(), b.ConfigID.String()), + ) + }) + // Sort tools by prefixed name for deterministic ordering // regardless of goroutine completion order. Ties, possible // when the __ separator produces ambiguous prefixed names, @@ -230,7 +312,7 @@ func connectAllWithHooks( } } - return tools, cleanup + return tools, summaries, cleanup } // connectOne establishes a connection to a single MCP server, diff --git a/coderd/x/chatd/mcpclient/mcpclient_connect_test.go b/coderd/x/chatd/mcpclient/mcpclient_connect_test.go index 2152786bcadda..a04e3b1c50308 100644 --- a/coderd/x/chatd/mcpclient/mcpclient_connect_test.go +++ b/coderd/x/chatd/mcpclient/mcpclient_connect_test.go @@ -82,7 +82,7 @@ func TestConnectAll_BlackHoledServerBudget(t *testing.T) { timeout := 1 * time.Second start := time.Now() - tools, cleanup := mcpclient.ConnectAllForTest(ctx, logger, + tools, summaries, cleanup := mcpclient.ConnectAllForTest(ctx, logger, []database.MCPServerConfig{ makeConfig("blackhole", bh.url()), makeConfig("healthy", healthy.URL), @@ -99,6 +99,17 @@ func TestConnectAll_BlackHoledServerBudget(t *testing.T) { "ConnectAll took %s, budget was %s", elapsed, timeout) require.Equal(t, []string{"healthy__echo"}, toolNames(tools)) + // Per-server outcomes are summarized for logs and debug runs, + // sorted by slug. + require.Len(t, summaries, 2) + require.Equal(t, "blackhole", summaries[0].Slug) + require.Equal(t, mcpclient.ConnectOutcomeTimeout, summaries[0].Outcome) + require.NotEmpty(t, summaries[0].Error) + require.GreaterOrEqual(t, summaries[0].DurationMS, timeout.Milliseconds()) + require.Equal(t, "healthy", summaries[1].Slug) + require.Equal(t, mcpclient.ConnectOutcomeConnected, summaries[1].Outcome) + require.Equal(t, 1, summaries[1].ToolCount) + // Terminating the black-holed connections unblocks the // abandoned connect goroutine; the reaper must then drain its // result and exit. @@ -131,7 +142,7 @@ func TestConnectAll_SlowServerStillConnects(t *testing.T) { t.Cleanup(ts.Close) cfg := makeConfig("slow", ts.URL) - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + 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)) @@ -163,7 +174,7 @@ func TestConnectAll_LateServerReaped(t *testing.T) { timeout := 500 * time.Millisecond start := time.Now() - tools, cleanup := mcpclient.ConnectAllForTest(ctx, logger, + tools, summaries, cleanup := mcpclient.ConnectAllForTest(ctx, logger, []database.MCPServerConfig{makeConfig("late", ts.URL)}, timeout, func() { reaperDone <- struct{}{} }, @@ -174,6 +185,8 @@ func TestConnectAll_LateServerReaped(t *testing.T) { require.Less(t, elapsed, 4*timeout, "ConnectAll took %s, budget was %s", elapsed, timeout) require.Empty(t, tools) + require.Len(t, summaries, 1) + require.Equal(t, mcpclient.ConnectOutcomeTimeout, summaries[0].Outcome) select { case <-reaperDone: @@ -223,7 +236,7 @@ func TestConnectAll_CleanupPromptWhenServerWedges(t *testing.T) { t.Cleanup(release) cfg := makeConfig("wedge", ts.URL) - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + 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() @@ -277,11 +290,13 @@ func TestConnectAll_NoToolsWedgedCloseWithinBudget(t *testing.T) { cfg := makeConfig("notools", ts.URL) start := time.Now() - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, summaries, 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.Len(t, summaries, 1) + require.Equal(t, mcpclient.ConnectOutcomeNoTools, summaries[0].Outcome) require.Less(t, elapsed, 5*time.Second, "ConnectAll took %s with a wedged no-tools teardown", elapsed) diff --git a/coderd/x/chatd/mcpclient/mcpclient_test.go b/coderd/x/chatd/mcpclient/mcpclient_test.go index c844b68b1913e..82434a9ace5e8 100644 --- a/coderd/x/chatd/mcpclient/mcpclient_test.go +++ b/coderd/x/chatd/mcpclient/mcpclient_test.go @@ -134,7 +134,7 @@ func TestConnectAll_DiscoverTools(t *testing.T) { ts := newTestMCPServer(t, echoTool(), greetTool()) cfg := makeConfig("myserver", ts.URL) - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) // Two tools should be discovered, namespaced with the server slug. @@ -161,7 +161,7 @@ func TestConnectAll_SanitizesDottedSlug(t *testing.T) { // Use a dotted slug like awslabs.* MCP servers ship with. // Dots violate Bedrock's tool name pattern ^[a-zA-Z0-9_-]{1,128}$. cfg := makeConfig("awslabs.aws-documentation-mcp-server", ts.URL) - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -200,7 +200,7 @@ func TestConnectAll_TruncationCollisionWarning(t *testing.T) { cfg1 := makeConfig(slug1, ts.URL) cfg2 := makeConfig(slug2, ts.URL) - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg1, cfg2}, nil, uuid.Nil, nil, nil, @@ -222,7 +222,7 @@ func TestConnectAll_CallTool(t *testing.T) { ts := newTestMCPServer(t, echoTool()) cfg := makeConfig("srv", ts.URL) - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -248,7 +248,7 @@ func TestConnectAll_ToolAllowList(t *testing.T) { // Only allow the "echo" tool. cfg.ToolAllowList = []string{"echo"} - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -266,7 +266,7 @@ func TestConnectAll_ToolDenyList(t *testing.T) { // Deny the "greet" tool, so only "echo" remains. cfg.ToolDenyList = []string{"greet"} - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -280,7 +280,7 @@ func TestConnectAll_ConnectionFailure(t *testing.T) { cfg := makeConfig("bad", "http://127.0.0.1:0/does-not-exist") - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) assert.Empty(t, tools, "no tools should be returned for an unreachable server") @@ -297,7 +297,7 @@ func TestConnectAll_MultipleServers(t *testing.T) { cfg1 := makeConfig("alpha", ts1.URL) cfg2 := makeConfig("beta", ts2.URL) - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg1, cfg2}, nil, @@ -323,7 +323,7 @@ func TestConnectAll_NoToolsAfterFiltering(t *testing.T) { cfg := makeConfig("filtered", ts.URL) cfg.ToolAllowList = []string{"greet"} - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg}, @@ -348,7 +348,7 @@ func TestConnectAll_DeterministicOrder(t *testing.T) { ts2 := newTestMCPServer(t, makeTool("alpha")) ts3 := newTestMCPServer(t, makeTool("middle")) - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{ @@ -379,7 +379,7 @@ func TestConnectAll_DeterministicOrder(t *testing.T) { multi := newTestMCPServer(t, makeTool("zeta"), makeTool("beta")) other := newTestMCPServer(t, makeTool("gamma")) - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{ @@ -416,7 +416,7 @@ func TestConnectAll_DeterministicOrder(t *testing.T) { cfg2 := makeConfig("a__b", ts2.URL) cfg2.ID = uuid.MustParse("00000000-0000-0000-0000-000000000001") - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg1, cfg2}, @@ -479,7 +479,7 @@ func TestConnectAll_AuthHeaders(t *testing.T) { TokenType: "Bearer", } - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg}, []database.MCPServerUserToken{token}, @@ -539,7 +539,7 @@ func TestConnectAll_DisabledServer(t *testing.T) { cfg := makeConfig("disabled", ts.URL) cfg.Enabled = false - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) assert.Empty(t, tools) } @@ -554,7 +554,7 @@ func TestConnectAll_CallToolInvalidInput(t *testing.T) { ts := newTestMCPServer(t, echoTool()) cfg := makeConfig("srv", ts.URL) - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -579,7 +579,7 @@ func TestConnectAll_ToolInfoParameters(t *testing.T) { ts := newTestMCPServer(t, echoTool()) cfg := makeConfig("srv", ts.URL) - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -630,7 +630,7 @@ func TestConnectAll_NilRequiredBecomesEmptySlice(t *testing.T) { ts := newTestMCPServer(t, noRequiredTool) cfg := makeConfig("srv", ts.URL) - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -677,7 +677,7 @@ func TestConnectAll_APIKeyAuth(t *testing.T) { cfg.APIKeyHeader = "X-API-Key" cfg.APIKeyValue = "secret-123" - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil, @@ -732,7 +732,7 @@ func TestConnectAll_CustomHeadersAuth(t *testing.T) { cfg.AuthType = "custom_headers" cfg.CustomHeaders = `{"X-Custom-Auth":"custom-val"}` - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil, @@ -770,7 +770,7 @@ func TestConnectAll_CustomHeadersInvalidJSON(t *testing.T) { cfg.AuthType = "custom_headers" cfg.CustomHeaders = "{not json}" - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil, @@ -827,7 +827,7 @@ func TestConnectAll_UserOIDCAuth(t *testing.T) { userID := uuid.New() src := staticOIDCSource{token: "fake-oidc-token"} - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg}, nil, userID, src, nil, ) @@ -883,7 +883,7 @@ func TestConnectAll_UserOIDCAuth_NoLink(t *testing.T) { cfg.AuthType = "user_oidc" src := staticOIDCSource{token: "", err: nil} - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.New(), src, nil, ) @@ -919,7 +919,7 @@ func TestConnectAll_UserOIDCAuth_NilSource(t *testing.T) { cfg := makeConfig("oidc-nilsrc", ts.URL) cfg.AuthType = "user_oidc" - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.New(), nil, nil, ) @@ -945,7 +945,7 @@ func TestConnectAll_ParallelConnections(t *testing.T) { cfg2 := makeConfig("srv2", ts2.URL) cfg3 := makeConfig("srv3", ts3.URL) - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg1, cfg2, cfg3}, nil, @@ -1011,7 +1011,7 @@ func TestConnectAll_ExpiredToken(t *testing.T) { Expiry: sql.NullTime{Time: time.Now().Add(-1 * time.Hour), Valid: true}, } - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, []database.MCPServerUserToken{token}, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, []database.MCPServerUserToken{token}, uuid.Nil, nil, nil) t.Cleanup(cleanup) // The server accepts any auth, so the tool is still discovered @@ -1044,7 +1044,7 @@ func TestConnectAll_EmptyAccessToken(t *testing.T) { TokenType: "Bearer", } - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, []database.MCPServerUserToken{token}, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, []database.MCPServerUserToken{token}, uuid.Nil, nil, nil) t.Cleanup(cleanup) // Tool is still discovered (server doesn't require auth), but @@ -1074,7 +1074,7 @@ func TestConnectAll_MCPToolIdentifier(t *testing.T) { Enabled: true, } - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -1116,7 +1116,7 @@ func TestConnectAll_MCPToolIdentifier_MultipleServers(t *testing.T) { Enabled: true, } - tools, cleanup := mcpclient.ConnectAll( + tools, _, cleanup := mcpclient.ConnectAll( ctx, logger, []database.MCPServerConfig{cfg1, cfg2}, nil, @@ -1171,7 +1171,7 @@ func TestConnectAll_EmbeddedResourceText(t *testing.T) { }) cfg := makeConfig("embed-txt", ts.URL) - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -1234,7 +1234,7 @@ func TestConnectAll_EmbeddedResourceBlob(t *testing.T) { }) cfg := makeConfig("embed-blob", ts.URL) - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -1309,7 +1309,7 @@ func TestConnectAll_ResourceLink(t *testing.T) { }) cfg := makeConfig("res-link", ts.URL) - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -1351,7 +1351,7 @@ func TestConnectAll_CallToolError(t *testing.T) { }) cfg := makeConfig("err-srv", ts.URL) - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -1375,7 +1375,7 @@ func TestModelIntent_Info_WrapsSchema(t *testing.T) { cfg := makeConfig("intent-srv", ts.URL) cfg.ModelIntent = true - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -1411,7 +1411,7 @@ func TestModelIntent_Info_NoWrapWhenDisabled(t *testing.T) { cfg := makeConfig("no-intent", ts.URL) cfg.ModelIntent = false - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -1434,7 +1434,7 @@ func TestModelIntent_Run_UnwrapsProperties(t *testing.T) { cfg := makeConfig("unwrap-srv", ts.URL) cfg.ModelIntent = true - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -1459,7 +1459,7 @@ func TestModelIntent_Run_UnwrapsFlat(t *testing.T) { cfg := makeConfig("flat-srv", ts.URL) cfg.ModelIntent = true - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -1484,7 +1484,7 @@ func TestModelIntent_Run_PassthroughWhenDisabled(t *testing.T) { cfg := makeConfig("pass-srv", ts.URL) cfg.ModelIntent = false - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) @@ -1509,7 +1509,7 @@ func TestModelIntent_Run_FallbackOnBadJSON(t *testing.T) { cfg := makeConfig("bad-srv", ts.URL) cfg.ModelIntent = true - tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) + tools, _, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) require.Len(t, tools, 1) diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx index b452a6fe30b5c..a905a6a4c1111 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx @@ -712,6 +712,76 @@ export const RunWithNoSteps: Story = { }, }; +const mcpConnectRunId = "run-mcp-connect"; +const mcpConnectSummary = { + first_message: "MCP connect probe", + mcp_connect: [ + { + config_id: "b8f9f3f2-4a3f-4f8a-9c5e-2f4f4be00001", + slug: "linear", + outcome: "connected", + duration_ms: 320, + tool_count: 12, + }, + { + config_id: "b8f9f3f2-4a3f-4f8a-9c5e-2f4f4be00002", + slug: "registry", + outcome: "timeout", + duration_ms: 10000, + error: "connect: context deadline exceeded", + }, + ], +}; + +export const RunWithMCPConnectSummary: Story = { + parameters: { + queries: [ + { + key: chatDebugRunsKey(CHAT_ID), + data: [ + buildRunSummary({ + id: mcpConnectRunId, + summary: mcpConnectSummary, + }), + ], + }, + { + key: chatDebugRunKey(CHAT_ID, mcpConnectRunId), + data: { + ...MockRun, + id: mcpConnectRunId, + summary: mcpConnectSummary, + steps: [], + }, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + const runTrigger = await canvas.findByRole("button", { + name: /MCP connect probe/i, + }); + await user.click(runTrigger); + + const section = await canvas.findByRole("region", { + name: /MCP server connections/i, + }); + const mcp = within(section); + await waitFor(() => { + expect(mcp.getByText("linear")).toBeVisible(); + expect(mcp.getByText("connected")).toBeVisible(); + expect(mcp.getByText("320ms")).toBeVisible(); + expect(mcp.getByText("12 tools")).toBeVisible(); + expect(mcp.getByText("registry")).toBeVisible(); + expect(mcp.getByText("timeout")).toBeVisible(); + expect(mcp.getByText("10.0s")).toBeVisible(); + expect(mcp.getByText("connect: context deadline exceeded")).toBeVisible(); + }); + }, +}; + // --------------------------------------------------------------------------- // Core state stories. // --------------------------------------------------------------------------- diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx index 2831806256379..99887422fc319 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx @@ -1,6 +1,6 @@ import { saveAs } from "file-saver"; import { ChevronDownIcon, DownloadIcon } from "lucide-react"; -import { type FC, useState } from "react"; +import { type FC, useId, useState } from "react"; import { useQuery } from "react-query"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; @@ -49,6 +49,17 @@ const getDurationLabel = (startedAt: string, finishedAt?: string): string => { return durationMs !== null ? compactDuration(durationMs) : "-"; }; +const getMCPOutcomeBadgeVariant = (outcome: string) => { + switch (outcome) { + case "connected": + return "green"; + case "no_tools": + return "default"; + default: + return "destructive"; + } +}; + export const DebugRunCard: FC = ({ run, chatId, @@ -57,6 +68,7 @@ export const DebugRunCard: FC = ({ }) => { const [isExpanded, setIsExpanded] = useState(false); const [isExporting, setIsExporting] = useState(false); + const mcpConnectHeadingId = useId(); const runDetailQuery = useQuery({ ...chatDebugRun(chatId, run.id), enabled: isVisible && isExpanded, @@ -200,6 +212,58 @@ export const DebugRunCard: FC = ({

) : null} + {summaryVm.mcpConnect.length > 0 ? ( +
+

+ MCP server connections +

+
    + {summaryVm.mcpConnect.map((server) => ( +
  • + + {server.slug} + + + {server.outcome} + + {server.durationMs !== undefined ? ( + + {compactDuration(server.durationMs)} + + ) : null} + {server.toolCount !== undefined && + server.toolCount > 0 ? ( + + {server.toolCount}{" "} + {server.toolCount === 1 ? "tool" : "tools"} + + ) : null} + {server.error ? ( + + {server.error} + + ) : null} +
  • + ))} +
+
+ ) : null} {steps.map((step) => ( ))} diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts index 0b0866d6456ca..5d9684d7bf637 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts @@ -826,6 +826,7 @@ describe("coerceRunSummary", () => { stepCount: 3, totalInputTokens: 120, totalOutputTokens: 45, + mcpConnect: [], warnings: [], }); }); @@ -851,10 +852,53 @@ describe("coerceRunSummary", () => { stepCount: undefined, totalInputTokens: undefined, totalOutputTokens: undefined, + mcpConnect: [], warnings: [], }); }); + it("coerces MCP connect summaries and drops malformed entries", () => { + const summary = coerceRunSummary({ + mcp_connect: [ + { + slug: "registry", + outcome: "timeout", + duration_ms: 10000, + error: "connect: context deadline exceeded", + }, + { + slug: "linear", + outcome: "connected", + duration_ms: 320, + tool_count: 12, + }, + { outcome: "error" }, + "not-a-record", + ], + }); + + expect(summary.mcpConnect).toEqual([ + { + slug: "registry", + outcome: "timeout", + durationMs: 10000, + toolCount: undefined, + error: "connect: context deadline exceeded", + }, + { + slug: "linear", + outcome: "connected", + durationMs: 320, + toolCount: 12, + error: undefined, + }, + ]); + }); + + it("returns an empty MCP connect list for non-array values", () => { + expect(coerceRunSummary({ mcp_connect: "oops" }).mcpConnect).toEqual([]); + }); + it("unwraps JSON-string payloads before coercing", () => { const summary = coerceRunSummary( JSON.stringify({ diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts index 9a9e570364d2d..39dd375bba076 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts @@ -436,6 +436,14 @@ export const compactDuration = (ms: number): string => { // View-model types for coerced debug payloads. // --------------------------------------------------------------------------- +export interface MCPConnectSummaryViewModel { + slug: string; + outcome: string; + durationMs: number | undefined; + toolCount: number | undefined; + error: string | undefined; +} + interface RunSummaryViewModel { primaryLabel: string; endpointLabel: string | undefined; @@ -444,6 +452,7 @@ interface RunSummaryViewModel { stepCount: number | undefined; totalInputTokens: number | undefined; totalOutputTokens: number | undefined; + mcpConnect: MCPConnectSummaryViewModel[]; warnings: string[]; } @@ -868,6 +877,32 @@ const extractKnownFields = ( // Public coercion: run summary. // --------------------------------------------------------------------------- +const coerceMCPConnectSummaries = ( + value: unknown, +): MCPConnectSummaryViewModel[] => { + if (!Array.isArray(value)) { + return []; + } + const result: MCPConnectSummaryViewModel[] = []; + for (const entry of value) { + if (!isRecord(entry)) { + continue; + } + const slug = toOptionalString(entry.slug); + if (!slug) { + continue; + } + result.push({ + slug, + outcome: toOptionalString(entry.outcome) ?? "unknown", + durationMs: toFiniteNumber(entry.duration_ms), + toolCount: toFiniteNumber(entry.tool_count), + error: toOptionalString(entry.error), + }); + } + return result; +}; + export const coerceRunSummary = (data: unknown): RunSummaryViewModel => { const defaults: RunSummaryViewModel = { primaryLabel: "", @@ -877,6 +912,7 @@ export const coerceRunSummary = (data: unknown): RunSummaryViewModel => { stepCount: undefined, totalInputTokens: undefined, totalOutputTokens: undefined, + mcpConnect: [], warnings: [], }; const parsed = deepParse(data); @@ -924,6 +960,9 @@ export const coerceRunSummary = (data: unknown): RunSummaryViewModel => { "completionTokens", ), ), + mcpConnect: coerceMCPConnectSummaries( + pickField(parsed, "mcp_connect", "mcpConnect"), + ), warnings: [], }; }; From ab9dab51c9cc0115bf2d4815b60e105fe7fb42f0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:47:40 +0000 Subject: [PATCH 2/8] feat(site/src/pages/AgentsPage): label non-chat-turn debug runs by kind A failed title generation or quickgen run with a first_message label was indistinguishable from a failed chat turn in the debug panel. Show the run kind in the card metadata whenever a non-chat-turn run carries a first_message label. --- .../DebugPanel/DebugPanel.stories.tsx | 40 +++++++++++++++++++ .../RightPanel/DebugPanel/DebugRunCard.tsx | 9 +++++ 2 files changed, 49 insertions(+) diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx index a905a6a4c1111..131299431ad63 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx @@ -1289,6 +1289,46 @@ export const CompactionAndTitleGenerationBadges: Story = { }, }; +export const NonChatTurnKindShownWithFirstMessage: Story = { + parameters: { + queries: [ + { + key: chatDebugRunsKey(CHAT_ID), + data: [ + buildRunSummary({ + id: "run-title-with-label", + kind: "title_generation", + status: "error", + model: "gpt-4o-mini", + summary: { first_message: "Summarize my workspace" }, + }), + buildRunSummary({ + id: "run-turn-with-label", + kind: "chat_turn", + status: "completed", + summary: { first_message: "Fix the login bug" }, + }), + ], + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // A failed title generation with a first_message label must + // still identify itself by kind so it is distinguishable from + // a failed chat turn. + const titleRun = await canvas.findByRole("button", { + name: /Summarize my workspace/i, + }); + await expect(within(titleRun).getByText("Title Generation")).toBeVisible(); + // Chat turns keep their metadata kind-free. + const chatRun = await canvas.findByRole("button", { + name: /Fix the login bug/i, + }); + expect(within(chatRun).queryByText("Chat Turn")).toBeNull(); + }, +}; + export const LongRawPayloads: Story = { parameters: { queries: [ diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx index 99887422fc319..895d47ea2c703 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx @@ -97,7 +97,16 @@ export const DebugRunCard: FC = ({ // Step count from detail or summary. const stepCount = steps.length > 0 ? steps.length : summaryVm.stepCount; const durationLabel = getDurationLabel(run.started_at, run.finished_at); + // Non-chat-turn runs (title generation, quickgen, compaction) + // usually carry a first_message label that hides the kind, so + // surface the kind in the metadata; otherwise a failed title + // generation is indistinguishable from a failed chat turn. + const kindLabel = + run.kind !== "chat_turn" && summaryVm.primaryLabel.trim() + ? getRunKindLabel(run.kind) + : undefined; const metadataItems = [ + kindLabel, modelLabel || undefined, stepCount !== undefined && stepCount > 0 ? `${stepCount} ${stepCount === 1 ? "step" : "steps"}` From 304f86705e6508c21c2f6006162e592d8cc32bc3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:42:38 +0000 Subject: [PATCH 3/8] fix(coderd/x/chatd): record MCP connect outcomes from every generation preparation --- coderd/x/chatd/active_turn_debug.go | 20 +++++ .../chatd/active_turn_debug_internal_test.go | 73 +++++++++++++++++++ .../DebugPanel/DebugPanel.stories.tsx | 15 +++- .../RightPanel/DebugPanel/DebugRunCard.tsx | 4 +- 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/coderd/x/chatd/active_turn_debug.go b/coderd/x/chatd/active_turn_debug.go index d9c2721496d61..716e14afce753 100644 --- a/coderd/x/chatd/active_turn_debug.go +++ b/coderd/x/chatd/active_turn_debug.go @@ -9,6 +9,7 @@ import ( "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/chatdebug" + "github.com/coder/coder/v2/coderd/x/chatd/mcpclient" ) type runnerDebugTurn struct { @@ -57,6 +58,7 @@ func (d *runnerDebugTurn) Ensure( return ctx } if d.created { + d.mergeMCPConnectSummariesLocked(debug) return d.contextLocked(ctx) } if debug == nil || !debug.Enabled || debug.Service == nil || @@ -131,6 +133,24 @@ func (d *runnerDebugTurn) Context(ctx context.Context) context.Context { return d.contextLocked(ctx) } +// mergeMCPConnectSummariesLocked appends a later preparation's +// per-server MCP connect outcomes to the seeded mcp_connect key. +// chatd reconnects to every configured MCP server on each +// generation step while the run is created only once, so without +// this merge only the first preparation's outcomes would survive +// to the finalized run and a server that degrades mid-turn would +// still be reported as connected. +func (d *runnerDebugTurn) mergeMCPConnectSummariesLocked(debug *generationDebug) { + if debug == nil || len(debug.MCPConnectSummaries) == 0 { + return + } + if d.seedSummary == nil { + d.seedSummary = make(map[string]any, 1) + } + existing, _ := d.seedSummary["mcp_connect"].([]mcpclient.ConnectSummary) + d.seedSummary["mcp_connect"] = append(existing, debug.MCPConnectSummaries...) +} + func (d *runnerDebugTurn) contextLocked(ctx context.Context) context.Context { if !d.created || d.runContext.RunID == uuid.Nil { return ctx diff --git a/coderd/x/chatd/active_turn_debug_internal_test.go b/coderd/x/chatd/active_turn_debug_internal_test.go index f599021853eaf..3b3e515675ad1 100644 --- a/coderd/x/chatd/active_turn_debug_internal_test.go +++ b/coderd/x/chatd/active_turn_debug_internal_test.go @@ -3,6 +3,7 @@ package chatd import ( "context" "database/sql" + "encoding/json" "testing" "github.com/google/uuid" @@ -12,6 +13,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/x/chatd/chatdebug" + "github.com/coder/coder/v2/coderd/x/chatd/mcpclient" "github.com/coder/coder/v2/testutil" ) @@ -153,3 +155,74 @@ func TestRunnerDebugTurnFinalizeOnce(t *testing.T) { turn.Finalize(ctx) turn.Finalize(ctx) } + +func TestRunnerDebugTurnEnsureMergesMCPConnectSummaries(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + runnerCtx, cancel := context.WithCancel(ctx) + defer cancel() + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + runID := uuid.New() + configID := uuid.New() + svc := chatdebug.NewService(db, testutil.Logger(t), nil) + turn := newRunnerDebugTurn(runnerCtx, testutil.Logger(t)) + + db.EXPECT().InsertChatDebugRun(gomock.Any(), gomock.Any()). + Return(database.ChatDebugRun{ + ID: runID, + ChatID: chatID, + Kind: string(chatdebug.KindChatTurn), + Status: string(chatdebug.StatusInProgress), + }, nil). + Times(1) + db.EXPECT().GetChatDebugStepsByRunID(gomock.Any(), runID).Return(nil, nil).Times(1) + var finalSummary []byte + db.EXPECT().UpdateChatDebugRun(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, params database.UpdateChatDebugRunParams) (database.ChatDebugRun, error) { + require.True(t, params.Summary.Valid) + finalSummary = params.Summary.RawMessage + return database.ChatDebugRun{ID: runID, ChatID: chatID}, nil + }).Times(1) + + base := generationDebug{ + Enabled: true, + Service: svc, + TriggerMessageID: 1, + ModelConfig: database.ChatModelConfig{ID: uuid.New()}, + } + first := base + first.MCPConnectSummaries = []mcpclient.ConnectSummary{{ + ConfigID: configID, + Slug: "registry", + Outcome: mcpclient.ConnectOutcomeConnected, + DurationMS: 17, + ToolCount: 1, + }} + second := base + second.MCPConnectSummaries = []mcpclient.ConnectSummary{{ + ConfigID: configID, + Slug: "registry", + Outcome: mcpclient.ConnectOutcomeTimeout, + DurationMS: 10000, + Error: "connect: context deadline exceeded", + }} + + turn.Ensure(ctx, database.Chat{ID: chatID}, &first) + // A later generation step reconnects and reports a degraded + // outcome for the same server; it must survive to the + // finalized summary. + turn.Ensure(ctx, database.Chat{ID: chatID}, &second) + turn.RecordOutcome(chatdebug.StatusCompleted) + turn.Finalize(ctx) + + var summary struct { + MCPConnect []mcpclient.ConnectSummary `json:"mcp_connect"` + } + require.NoError(t, json.Unmarshal(finalSummary, &summary)) + require.Len(t, summary.MCPConnect, 2) + require.Equal(t, mcpclient.ConnectOutcomeConnected, summary.MCPConnect[0].Outcome) + require.Equal(t, mcpclient.ConnectOutcomeTimeout, summary.MCPConnect[1].Outcome) +} diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx index 131299431ad63..e59c896024a30 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx @@ -730,6 +730,15 @@ const mcpConnectSummary = { duration_ms: 10000, error: "connect: context deadline exceeded", }, + // The same server reported again by a later generation + // step; entries accumulate across the turn's preparations. + { + config_id: "b8f9f3f2-4a3f-4f8a-9c5e-2f4f4be00002", + slug: "registry", + outcome: "connected", + duration_ms: 45, + tool_count: 3, + }, ], }; @@ -771,13 +780,15 @@ export const RunWithMCPConnectSummary: Story = { const mcp = within(section); await waitFor(() => { expect(mcp.getByText("linear")).toBeVisible(); - expect(mcp.getByText("connected")).toBeVisible(); + expect(mcp.getAllByText("connected")).toHaveLength(2); expect(mcp.getByText("320ms")).toBeVisible(); expect(mcp.getByText("12 tools")).toBeVisible(); - expect(mcp.getByText("registry")).toBeVisible(); + expect(mcp.getAllByText("registry")).toHaveLength(2); expect(mcp.getByText("timeout")).toBeVisible(); expect(mcp.getByText("10.0s")).toBeVisible(); expect(mcp.getByText("connect: context deadline exceeded")).toBeVisible(); + expect(mcp.getByText("45ms")).toBeVisible(); + expect(mcp.getByText("3 tools")).toBeVisible(); }); }, }; diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx index 895d47ea2c703..b7d6facb4b706 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx @@ -233,9 +233,9 @@ export const DebugRunCard: FC = ({ MCP server connections
    - {summaryVm.mcpConnect.map((server) => ( + {summaryVm.mcpConnect.map((server, index) => (
  • From e3ed5ed4f177cefb4c284c333d09fa9168bdba56 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:08:56 +0000 Subject: [PATCH 4/8] fix(coderd/x/chatd): record MCP connect outcomes at the generation dispatch point --- coderd/x/chatd/active_turn_debug.go | 47 ++++++--- .../chatd/active_turn_debug_internal_test.go | 37 +++++-- coderd/x/chatd/chatd_test.go | 99 +++++++++++++++++++ coderd/x/chatd/generation.go | 6 ++ 4 files changed, 165 insertions(+), 24 deletions(-) diff --git a/coderd/x/chatd/active_turn_debug.go b/coderd/x/chatd/active_turn_debug.go index 716e14afce753..be68df03af433 100644 --- a/coderd/x/chatd/active_turn_debug.go +++ b/coderd/x/chatd/active_turn_debug.go @@ -58,7 +58,6 @@ func (d *runnerDebugTurn) Ensure( return ctx } if d.created { - d.mergeMCPConnectSummariesLocked(debug) return d.contextLocked(ctx) } if debug == nil || !debug.Enabled || debug.Service == nil || @@ -70,15 +69,16 @@ func (d *runnerDebugTurn) Ensure( seedSummary := chatdebug.SeedSummary( chatdebug.TruncateLabel(debug.TriggerLabel, chatdebug.MaxLabelLength), ) - // Seed per-server MCP connect outcomes so slow or failing - // servers appear in the run instead of as a silent gap before - // the first step. Seeded keys survive FinalizeRun's summary - // aggregation. - if len(debug.MCPConnectSummaries) > 0 { + // Carry per-server MCP connect outcomes stashed by + // RecordMCPConnectSummaries before the run existed, so slow or + // failing servers appear in the run instead of as a silent gap + // before the first step. Seeded keys survive FinalizeRun's + // summary aggregation. + if stashed, ok := d.seedSummary["mcp_connect"]; ok { if seedSummary == nil { seedSummary = make(map[string]any, 1) } - seedSummary["mcp_connect"] = debug.MCPConnectSummaries + seedSummary["mcp_connect"] = stashed } rootChatID := uuid.Nil if chat.RootChatID.Valid { @@ -133,13 +133,32 @@ func (d *runnerDebugTurn) Context(ctx context.Context) context.Context { return d.contextLocked(ctx) } -// mergeMCPConnectSummariesLocked appends a later preparation's -// per-server MCP connect outcomes to the seeded mcp_connect key. -// chatd reconnects to every configured MCP server on each -// generation step while the run is created only once, so without -// this merge only the first preparation's outcomes would survive -// to the finalized run and a server that degrades mid-turn would -// still be reported as connected. +// RecordMCPConnectSummaries merges one preparation's per-server MCP +// connect outcomes into the mcp_connect summary key. It runs at the +// generation dispatch point so every preparation is recorded, +// including ones feeding actions that never reach Ensure (local +// tool execution, requires-action, turn finishing). Outcomes +// recorded before the run exists are stashed and seeded into the +// run when Ensure creates it. +func (d *runnerDebugTurn) RecordMCPConnectSummaries(debug *generationDebug) { + if d == nil { + return + } + d.mu.Lock() + defer d.mu.Unlock() + if d.disabled || d.finalized { + return + } + d.mergeMCPConnectSummariesLocked(debug) +} + +// mergeMCPConnectSummariesLocked appends a preparation's per-server +// MCP connect outcomes to the mcp_connect summary key. chatd +// reconnects to every configured MCP server on each generation step +// while the run is created only once, so without this merge only +// one preparation's outcomes would survive to the finalized run and +// a server that degrades mid-turn would still be reported as +// connected. func (d *runnerDebugTurn) mergeMCPConnectSummariesLocked(debug *generationDebug) { if debug == nil || len(debug.MCPConnectSummaries) == 0 { return diff --git a/coderd/x/chatd/active_turn_debug_internal_test.go b/coderd/x/chatd/active_turn_debug_internal_test.go index 3b3e515675ad1..e038b56990846 100644 --- a/coderd/x/chatd/active_turn_debug_internal_test.go +++ b/coderd/x/chatd/active_turn_debug_internal_test.go @@ -156,7 +156,7 @@ func TestRunnerDebugTurnFinalizeOnce(t *testing.T) { turn.Finalize(ctx) } -func TestRunnerDebugTurnEnsureMergesMCPConnectSummaries(t *testing.T) { +func TestRunnerDebugTurnRecordMCPConnectSummaries(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -170,13 +170,18 @@ func TestRunnerDebugTurnEnsureMergesMCPConnectSummaries(t *testing.T) { svc := chatdebug.NewService(db, testutil.Logger(t), nil) turn := newRunnerDebugTurn(runnerCtx, testutil.Logger(t)) + var seededSummary []byte db.EXPECT().InsertChatDebugRun(gomock.Any(), gomock.Any()). - Return(database.ChatDebugRun{ - ID: runID, - ChatID: chatID, - Kind: string(chatdebug.KindChatTurn), - Status: string(chatdebug.StatusInProgress), - }, nil). + DoAndReturn(func(_ context.Context, params database.InsertChatDebugRunParams) (database.ChatDebugRun, error) { + require.True(t, params.Summary.Valid) + seededSummary = params.Summary.RawMessage + return database.ChatDebugRun{ + ID: runID, + ChatID: chatID, + Kind: string(chatdebug.KindChatTurn), + Status: string(chatdebug.StatusInProgress), + }, nil + }). Times(1) db.EXPECT().GetChatDebugStepsByRunID(gomock.Any(), runID).Return(nil, nil).Times(1) var finalSummary []byte @@ -210,14 +215,26 @@ func TestRunnerDebugTurnEnsureMergesMCPConnectSummaries(t *testing.T) { Error: "connect: context deadline exceeded", }} + // The dispatch point records each preparation before its action + // runs, so the first outcome lands before Ensure creates the + // run and must be seeded into it. + turn.RecordMCPConnectSummaries(&first) turn.Ensure(ctx, database.Chat{ID: chatID}, &first) // A later generation step reconnects and reports a degraded - // outcome for the same server; it must survive to the - // finalized summary. - turn.Ensure(ctx, database.Chat{ID: chatID}, &second) + // outcome for the same server; its action may never reach + // Ensure, and the outcome must still survive to the finalized + // summary. + turn.RecordMCPConnectSummaries(&second) turn.RecordOutcome(chatdebug.StatusCompleted) turn.Finalize(ctx) + var seeded struct { + MCPConnect []mcpclient.ConnectSummary `json:"mcp_connect"` + } + require.NoError(t, json.Unmarshal(seededSummary, &seeded)) + require.Len(t, seeded.MCPConnect, 1) + require.Equal(t, mcpclient.ConnectOutcomeConnected, seeded.MCPConnect[0].Outcome) + var summary struct { MCPConnect []mcpclient.ConnectSummary `json:"mcp_connect"` } diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index bfc2c946e2c72..dfefa3cc20e07 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -11193,6 +11193,105 @@ func TestMCPServerToolInvocation(t *testing.T) { "MCP tool result should be persisted as a tool message in the database") } +func TestActiveServer_ChatTurnDebugRunRecordsMCPConnectPerPreparation(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + mcpSrv := newTestMCPServer("test-mcp") + addTestMCPTextTool(mcpSrv, "echo", "Echoes the input", "echo: ") + mcpTS := httptest.NewServer(testMCPHTTPHandler(mcpSrv)) + t.Cleanup(mcpTS.Close) + + var callCount atomic.Int32 + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + if callCount.Add(1) == 1 { + return chattest.OpenAIStreamingResponse( + chattest.OpenAIToolCallChunk( + "test-mcp__echo", + `{"input":"hello from LLM"}`, + ), + ) + } + return chattest.OpenAIStreamingResponse( + chattest.OpenAITextChunks("Got it!")..., + ) + }) + + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: org.ID, + DisplayName: "Test MCP", + Slug: "test-mcp", + Url: mcpTS.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + }) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + withoutMCPToolSearch(cfg) + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.AlwaysEnableDebugLogs = true + }) + + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "mcp-connect-debug-test", + ModelConfigID: model.ID, + MCPServerIDs: []uuid.UUID{mcpConfig.ID}, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("Echo something via MCP."), + }, + }) + require.NoError(t, err) + + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + require.NoError(t, server.Close()) + debugCtx := testutil.Context(t, testutil.WaitLong) + + var chatTurnRuns []database.ChatDebugRun + testutil.Eventually(debugCtx, t, func(ctx context.Context) bool { + runs, runsErr := db.GetChatDebugRunsByChatID(ctx, database.GetChatDebugRunsByChatIDParams{ + ChatID: chat.ID, + LimitVal: 100, + }) + if runsErr != nil { + return false + } + chatTurnRuns = chatTurnRuns[:0] + for _, run := range runs { + if run.Kind == string(codersdk.ChatDebugRunKindChatTurn) { + chatTurnRuns = append(chatTurnRuns, run) + } + } + return len(chatTurnRuns) == 1 && chatTurnRuns[0].FinishedAt.Valid + }, testutil.IntervalFast) + + require.Len(t, chatTurnRuns, 1) + var summary struct { + MCPConnect []struct { + Slug string `json:"slug"` + Outcome string `json:"outcome"` + } `json:"mcp_connect"` + } + require.NoError(t, json.Unmarshal(chatTurnRuns[0].Summary, &summary)) + // A tool-call turn runs at least three preparations: the + // assistant step that requests the tool, the local tool + // execution step, and the assistant step that consumes the + // result. Each preparation reconnects to the MCP server and + // must contribute its connect outcome to the finalized run. + require.GreaterOrEqual(t, len(summary.MCPConnect), 3) + for _, entry := range summary.MCPConnect { + require.Equal(t, "test-mcp", entry.Slug) + require.Equal(t, "connected", entry.Outcome) + } +} + func TestPlanModeRootChatApprovedExternalMCPToolInvocation(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index 5843e822966d2..f326a6d2ad3c7 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -495,6 +495,12 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS return s.finishGenerationError(ctx, machine, input, err, generationAttemptNotRequired) } + // Every preparation reconnects to MCP, including ones whose + // action below never reaches Ensure; record each + // preparation's connect outcomes at the single dispatch + // point. + input.DebugTurn.RecordMCPConnectSummaries(prepared.Debug) + var actionErr error switch decision.kind { case generationActionEnterRequiresAction: From 937fa5e8ed4841a661f4a9fb14640515456ea4ff Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:29:28 +0000 Subject: [PATCH 5/8] fix(coderd/x/chatd): record MCP connect outcomes before generation decisions --- coderd/x/chatd/chatd_test.go | 118 +++++++++++++++++++++++++++++++++++ coderd/x/chatd/generation.go | 11 ++-- 2 files changed, 123 insertions(+), 6 deletions(-) diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index dfefa3cc20e07..b1a766461e37e 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -11292,6 +11292,124 @@ func TestActiveServer_ChatTurnDebugRunRecordsMCPConnectPerPreparation(t *testing } } +func TestActiveServer_ChatTurnDebugRunRecordsMCPConnectOnDecisionError(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + mcpSrv := newTestMCPServer("test-mcp") + addTestMCPTextTool(mcpSrv, "echo", "Echoes the input", "echo: ") + mcpTS := httptest.NewServer(testMCPHTTPHandler(mcpSrv)) + t.Cleanup(mcpTS.Close) + + // Every streamed assistant response is a high-usage tool call, + // so the turn compacts once and the post-compaction assistant + // is still over the context limit: the next decision fails + // terminally with the still-over-limit error. + var streamCount atomic.Int32 + anthropicURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse { + if !req.Stream { + if strings.Contains(anthropicRequestBody(t, *req), "You are performing a context compaction") { + return anthropicCompactionResponse("summary text for compaction") + } + return chattest.AnthropicNonStreamingResponse("title") + } + if streamCount.Add(1) == 1 { + return highUsageReadFileResponse("/tmp/a.txt") + } + return highUsageReadFileResponse("/tmp/b.txt") + }) + + user, org, model := seedAnthropicChatDependencies(t, db, anthropicURL) + model = updateChatModelCompressionThreshold(t, db, model, 100, 70) + mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: org.ID, + DisplayName: "Test MCP", + Slug: "test-mcp", + Url: mcpTS.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + }) + ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) + + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + setupToolExecutionAgentConn(t, mockConn) + mockConn.EXPECT().ReadFileLines(gomock.Any(), "/tmp/a.txt", int64(1), int64(0), gomock.Any()). + Return(workspacesdk.ReadFileLinesResponse{Success: true, FileSize: 12, TotalLines: 1, LinesRead: 1, Content: "1\tpackage main"}, nil). + Times(1) + mockConn.EXPECT().ReadFileLines(gomock.Any(), "/tmp/b.txt", int64(1), int64(0), gomock.Any()). + Return(workspacesdk.ReadFileLinesResponse{Success: true, FileSize: 12, TotalLines: 1, LinesRead: 1, Content: "1\tpackage main"}, nil). + Times(1) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + withoutMCPToolSearch(cfg) + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, anthropicURL, chattest.WithPreservePath())) + cfg.AlwaysEnableDebugLogs = true + cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { + require.Equal(t, dbAgent.ID, agentID) + return mockConn, func() {}, nil + } + }) + + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, + Title: "mcp-connect-decision-error-test", + ModelConfigID: model.ID, + MCPServerIDs: []uuid.UUID{mcpConfig.ID}, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("read the files"), + }, + }) + require.NoError(t, err) + + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusError) + require.NoError(t, server.Close()) + debugCtx := testutil.Context(t, testutil.WaitLong) + + var chatTurnRuns []database.ChatDebugRun + testutil.Eventually(debugCtx, t, func(ctx context.Context) bool { + runs, runsErr := db.GetChatDebugRunsByChatID(ctx, database.GetChatDebugRunsByChatIDParams{ + ChatID: chat.ID, + LimitVal: 100, + }) + if runsErr != nil { + return false + } + chatTurnRuns = chatTurnRuns[:0] + for _, run := range runs { + if run.Kind == string(codersdk.ChatDebugRunKindChatTurn) { + chatTurnRuns = append(chatTurnRuns, run) + } + } + return len(chatTurnRuns) == 1 && chatTurnRuns[0].FinishedAt.Valid + }, testutil.IntervalFast) + + require.Len(t, chatTurnRuns, 1) + var summary struct { + MCPConnect []struct { + Slug string `json:"slug"` + Outcome string `json:"outcome"` + } `json:"mcp_connect"` + } + require.NoError(t, json.Unmarshal(chatTurnRuns[0].Summary, &summary)) + // The turn performs six preparations: the first assistant step, + // its tool execution, the compaction, the post-compaction + // assistant step, its tool execution, and the preparation whose + // decision fails with still-over-limit. Each one must + // contribute its connect outcome, including the final + // preparation that exits through the decision error. + require.GreaterOrEqual(t, len(summary.MCPConnect), 6) + for _, entry := range summary.MCPConnect { + require.Equal(t, "test-mcp", entry.Slug) + require.Equal(t, "connected", entry.Outcome) + } +} + func TestPlanModeRootChatApprovedExternalMCPToolInvocation(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index f326a6d2ad3c7..d8785da92ce43 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -459,6 +459,11 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS return s.finishGenerationError(ctx, machine, input, err, generationAttemptNotRequired) } cleanup := prepared.Cleanup + // Every preparation reconnects to MCP; record its connect + // outcomes as soon as preparation succeeds so exits that + // never reach the action dispatch (decision errors and + // actions that skip Ensure) still contribute to the run. + input.DebugTurn.RecordMCPConnectSummaries(prepared.Debug) var decision generationDecision if input.StopNudges.consume(stopNudgeKey(prepared.Messages)) { decision = generationDecision{kind: generationActionGenerateAssistant} @@ -495,12 +500,6 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS return s.finishGenerationError(ctx, machine, input, err, generationAttemptNotRequired) } - // Every preparation reconnects to MCP, including ones whose - // action below never reaches Ensure; record each - // preparation's connect outcomes at the single dispatch - // point. - input.DebugTurn.RecordMCPConnectSummaries(prepared.Debug) - var actionErr error switch decision.kind { case generationActionEnterRequiresAction: From 08221f02f00fee37df95e4af0a0cab2649ca4ed6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:30:39 +0000 Subject: [PATCH 6/8] fix(coderd/x/chatd): record failing-preparation MCP connect outcomes and bound retention --- coderd/x/chatd/active_turn_debug.go | 54 ++++++--- .../chatd/active_turn_debug_internal_test.go | 109 +++++++++++++++-- coderd/x/chatd/chatd_test.go | 112 ++++++++++++++++++ coderd/x/chatd/generation.go | 20 ++-- coderd/x/chatd/generation_preparer.go | 12 +- 5 files changed, 263 insertions(+), 44 deletions(-) diff --git a/coderd/x/chatd/active_turn_debug.go b/coderd/x/chatd/active_turn_debug.go index be68df03af433..01c30d86910a2 100644 --- a/coderd/x/chatd/active_turn_debug.go +++ b/coderd/x/chatd/active_turn_debug.go @@ -69,16 +69,16 @@ func (d *runnerDebugTurn) Ensure( seedSummary := chatdebug.SeedSummary( chatdebug.TruncateLabel(debug.TriggerLabel, chatdebug.MaxLabelLength), ) - // Carry per-server MCP connect outcomes stashed by - // RecordMCPConnectSummaries before the run existed, so slow or - // failing servers appear in the run instead of as a silent gap - // before the first step. Seeded keys survive FinalizeRun's - // summary aggregation. - if stashed, ok := d.seedSummary["mcp_connect"]; ok { + // Carry per-server MCP connect outcomes (and their dropped + // count) stashed by RecordMCPConnectSummaries before the run + // existed, so slow or failing servers appear in the run instead + // of as a silent gap before the first step. Seeded keys survive + // FinalizeRun's summary aggregation. + for key, stashed := range d.seedSummary { if seedSummary == nil { - seedSummary = make(map[string]any, 1) + seedSummary = make(map[string]any, len(d.seedSummary)) } - seedSummary["mcp_connect"] = stashed + seedSummary[key] = stashed } rootChatID := uuid.Nil if chat.RootChatID.Valid { @@ -134,13 +134,14 @@ func (d *runnerDebugTurn) Context(ctx context.Context) context.Context { } // RecordMCPConnectSummaries merges one preparation's per-server MCP -// connect outcomes into the mcp_connect summary key. It runs at the -// generation dispatch point so every preparation is recorded, -// including ones feeding actions that never reach Ensure (local -// tool execution, requires-action, turn finishing). Outcomes -// recorded before the run exists are stashed and seeded into the -// run when Ensure creates it. -func (d *runnerDebugTurn) RecordMCPConnectSummaries(debug *generationDebug) { +// connect outcomes into the mcp_connect summary key. Preparation +// invokes it as soon as its MCP connect phase completes, so every +// attempt is recorded: preparations that fail after connecting, +// decision errors, and actions that never reach Ensure (local tool +// execution, requires-action, turn finishing). Outcomes recorded +// before the run exists are stashed and seeded into the run when +// Ensure creates it. +func (d *runnerDebugTurn) RecordMCPConnectSummaries(summaries []mcpclient.ConnectSummary) { if d == nil { return } @@ -149,9 +150,18 @@ func (d *runnerDebugTurn) RecordMCPConnectSummaries(debug *generationDebug) { if d.disabled || d.finalized { return } - d.mergeMCPConnectSummariesLocked(debug) + d.mergeMCPConnectSummariesLocked(summaries) } +// maxMCPConnectSummaryEntries bounds the retained per-preparation MCP +// connect outcomes. A turn may run up to 1,200 generation steps, each +// reconnecting to every selected server, so unbounded retention could +// grow one run summary to megabytes of database and API payload. The +// newest entries win because the tail of the history is what shows a +// server that degraded mid-turn; the mcp_connect_dropped count keeps +// the truncation visible. +const maxMCPConnectSummaryEntries = 100 + // mergeMCPConnectSummariesLocked appends a preparation's per-server // MCP connect outcomes to the mcp_connect summary key. chatd // reconnects to every configured MCP server on each generation step @@ -159,15 +169,21 @@ func (d *runnerDebugTurn) RecordMCPConnectSummaries(debug *generationDebug) { // one preparation's outcomes would survive to the finalized run and // a server that degrades mid-turn would still be reported as // connected. -func (d *runnerDebugTurn) mergeMCPConnectSummariesLocked(debug *generationDebug) { - if debug == nil || len(debug.MCPConnectSummaries) == 0 { +func (d *runnerDebugTurn) mergeMCPConnectSummariesLocked(summaries []mcpclient.ConnectSummary) { + if len(summaries) == 0 { return } if d.seedSummary == nil { d.seedSummary = make(map[string]any, 1) } existing, _ := d.seedSummary["mcp_connect"].([]mcpclient.ConnectSummary) - d.seedSummary["mcp_connect"] = append(existing, debug.MCPConnectSummaries...) + existing = append(existing, summaries...) + if over := len(existing) - maxMCPConnectSummaryEntries; over > 0 { + existing = existing[over:] + dropped, _ := d.seedSummary["mcp_connect_dropped"].(int) + d.seedSummary["mcp_connect_dropped"] = dropped + over + } + d.seedSummary["mcp_connect"] = existing } func (d *runnerDebugTurn) contextLocked(ctx context.Context) context.Context { diff --git a/coderd/x/chatd/active_turn_debug_internal_test.go b/coderd/x/chatd/active_turn_debug_internal_test.go index e038b56990846..59ed5de4b9701 100644 --- a/coderd/x/chatd/active_turn_debug_internal_test.go +++ b/coderd/x/chatd/active_turn_debug_internal_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "testing" "github.com/google/uuid" @@ -192,22 +193,20 @@ func TestRunnerDebugTurnRecordMCPConnectSummaries(t *testing.T) { return database.ChatDebugRun{ID: runID, ChatID: chatID}, nil }).Times(1) - base := generationDebug{ + debug := generationDebug{ Enabled: true, Service: svc, TriggerMessageID: 1, ModelConfig: database.ChatModelConfig{ID: uuid.New()}, } - first := base - first.MCPConnectSummaries = []mcpclient.ConnectSummary{{ + first := []mcpclient.ConnectSummary{{ ConfigID: configID, Slug: "registry", Outcome: mcpclient.ConnectOutcomeConnected, DurationMS: 17, ToolCount: 1, }} - second := base - second.MCPConnectSummaries = []mcpclient.ConnectSummary{{ + second := []mcpclient.ConnectSummary{{ ConfigID: configID, Slug: "registry", Outcome: mcpclient.ConnectOutcomeTimeout, @@ -215,16 +214,16 @@ func TestRunnerDebugTurnRecordMCPConnectSummaries(t *testing.T) { Error: "connect: context deadline exceeded", }} - // The dispatch point records each preparation before its action - // runs, so the first outcome lands before Ensure creates the - // run and must be seeded into it. - turn.RecordMCPConnectSummaries(&first) - turn.Ensure(ctx, database.Chat{ID: chatID}, &first) + // Preparation records each attempt as soon as its connect phase + // completes, so the first outcome lands before Ensure creates + // the run and must be seeded into it. + turn.RecordMCPConnectSummaries(first) + turn.Ensure(ctx, database.Chat{ID: chatID}, &debug) // A later generation step reconnects and reports a degraded // outcome for the same server; its action may never reach // Ensure, and the outcome must still survive to the finalized // summary. - turn.RecordMCPConnectSummaries(&second) + turn.RecordMCPConnectSummaries(second) turn.RecordOutcome(chatdebug.StatusCompleted) turn.Finalize(ctx) @@ -243,3 +242,91 @@ func TestRunnerDebugTurnRecordMCPConnectSummaries(t *testing.T) { require.Equal(t, mcpclient.ConnectOutcomeConnected, summary.MCPConnect[0].Outcome) require.Equal(t, mcpclient.ConnectOutcomeTimeout, summary.MCPConnect[1].Outcome) } + +func TestRunnerDebugTurnBoundsMCPConnectSummaries(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + runnerCtx, cancel := context.WithCancel(ctx) + defer cancel() + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + runID := uuid.New() + svc := chatdebug.NewService(db, testutil.Logger(t), nil) + turn := newRunnerDebugTurn(runnerCtx, testutil.Logger(t)) + + var seededSummary []byte + db.EXPECT().InsertChatDebugRun(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, params database.InsertChatDebugRunParams) (database.ChatDebugRun, error) { + require.True(t, params.Summary.Valid) + seededSummary = params.Summary.RawMessage + return database.ChatDebugRun{ + ID: runID, + ChatID: chatID, + Kind: string(chatdebug.KindChatTurn), + Status: string(chatdebug.StatusInProgress), + }, nil + }). + Times(1) + db.EXPECT().GetChatDebugStepsByRunID(gomock.Any(), runID).Return(nil, nil).Times(1) + var finalSummary []byte + db.EXPECT().UpdateChatDebugRun(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, params database.UpdateChatDebugRunParams) (database.ChatDebugRun, error) { + require.True(t, params.Summary.Valid) + finalSummary = params.Summary.RawMessage + return database.ChatDebugRun{ID: runID, ChatID: chatID}, nil + }).Times(1) + + // 25 preparations against 5 servers produce 125 outcomes, + // overflowing the cap before the run exists. + for prep := 0; prep < 25; prep++ { + batch := make([]mcpclient.ConnectSummary, 5) + for server := range batch { + batch[server] = mcpclient.ConnectSummary{ + ConfigID: uuid.New(), + Slug: fmt.Sprintf("server-%d", server), + Outcome: mcpclient.ConnectOutcomeConnected, + DurationMS: int64(prep), + } + } + turn.RecordMCPConnectSummaries(batch) + } + + debug := generationDebug{ + Enabled: true, + Service: svc, + TriggerMessageID: 1, + ModelConfig: database.ChatModelConfig{ID: uuid.New()}, + } + turn.Ensure(ctx, database.Chat{ID: chatID}, &debug) + + type boundedSummary struct { + MCPConnect []mcpclient.ConnectSummary `json:"mcp_connect"` + MCPConnectDropped int `json:"mcp_connect_dropped"` + } + var seeded boundedSummary + require.NoError(t, json.Unmarshal(seededSummary, &seeded)) + require.Len(t, seeded.MCPConnect, maxMCPConnectSummaryEntries) + require.Equal(t, 25, seeded.MCPConnectDropped) + // The newest outcomes win: the five oldest preparations were + // dropped, so the retained history starts at preparation 5. + require.Equal(t, int64(5), seeded.MCPConnect[0].DurationMS) + + // A preparation recorded after the run exists still respects + // the cap and grows the dropped count. + turn.RecordMCPConnectSummaries([]mcpclient.ConnectSummary{{ + ConfigID: uuid.New(), + Slug: "server-0", + Outcome: mcpclient.ConnectOutcomeTimeout, + DurationMS: 10000, + }}) + turn.RecordOutcome(chatdebug.StatusCompleted) + turn.Finalize(ctx) + + var final boundedSummary + require.NoError(t, json.Unmarshal(finalSummary, &final)) + require.Len(t, final.MCPConnect, maxMCPConnectSummaryEntries) + require.Equal(t, 26, final.MCPConnectDropped) + require.Equal(t, mcpclient.ConnectOutcomeTimeout, final.MCPConnect[maxMCPConnectSummaryEntries-1].Outcome) +} diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index b1a766461e37e..8dd69b11b7ed0 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -11410,6 +11410,118 @@ func TestActiveServer_ChatTurnDebugRunRecordsMCPConnectOnDecisionError(t *testin } } +// overrideReadFailStore wraps a database.Store so +// GetChatOrganizationModelOverride fails once the test arms it, +// making a later generation preparation fail after its MCP connect +// phase has already completed. +type overrideReadFailStore struct { + database.Store + fail *atomic.Bool +} + +func (s *overrideReadFailStore) GetChatOrganizationModelOverride(ctx context.Context, arg database.GetChatOrganizationModelOverrideParams) (database.ChatOrganizationModelOverride, error) { + if s.fail.Load() { + return database.ChatOrganizationModelOverride{}, xerrors.New("injected compaction override read failure") + } + return s.Store.GetChatOrganizationModelOverride(ctx, arg) +} + +func TestActiveServer_ChatTurnDebugRunRecordsMCPConnectOnPrepareError(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + mcpSrv := newTestMCPServer("test-mcp") + addTestMCPTextTool(mcpSrv, "echo", "Echoes the input", "echo: ") + mcpTS := httptest.NewServer(testMCPHTTPHandler(mcpSrv)) + t.Cleanup(mcpTS.Close) + + // Serving the first assistant stream arms the failure, so the + // next preparation completes its MCP connect phase and then + // fails reading the compaction override before returning a + // prepared generation. + var failOverrideReads atomic.Bool + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + failOverrideReads.Store(true) + return chattest.OpenAIStreamingResponse( + chattest.OpenAITextChunks("Done!")..., + ) + }) + + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: org.ID, + DisplayName: "Test MCP", + Slug: "test-mcp", + Url: mcpTS.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + }) + + server := newActiveTestServer(t, &overrideReadFailStore{Store: db, fail: &failOverrideReads}, ps, func(cfg *chatd.Config) { + withoutMCPToolSearch(cfg) + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.AlwaysEnableDebugLogs = true + }) + + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "mcp-connect-prepare-error-test", + ModelConfigID: model.ID, + MCPServerIDs: []uuid.UUID{mcpConfig.ID}, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("Say done."), + }, + }) + require.NoError(t, err) + + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusError) + require.NoError(t, server.Close()) + debugCtx := testutil.Context(t, testutil.WaitLong) + + var chatTurnRuns []database.ChatDebugRun + testutil.Eventually(debugCtx, t, func(ctx context.Context) bool { + runs, runsErr := db.GetChatDebugRunsByChatID(ctx, database.GetChatDebugRunsByChatIDParams{ + ChatID: chat.ID, + LimitVal: 100, + }) + if runsErr != nil { + return false + } + chatTurnRuns = chatTurnRuns[:0] + for _, run := range runs { + if run.Kind == string(codersdk.ChatDebugRunKindChatTurn) { + chatTurnRuns = append(chatTurnRuns, run) + } + } + return len(chatTurnRuns) == 1 && chatTurnRuns[0].FinishedAt.Valid + }, testutil.IntervalFast) + + require.Len(t, chatTurnRuns, 1) + var summary struct { + MCPConnect []struct { + Slug string `json:"slug"` + Outcome string `json:"outcome"` + } `json:"mcp_connect"` + } + require.NoError(t, json.Unmarshal(chatTurnRuns[0].Summary, &summary)) + // The preparation feeding the assistant step records one + // outcome. The following preparation connects to the MCP server + // and then fails reading the compaction override; its attempts + // must still contribute their connect outcomes even though + // preparation never returns a prepared generation. + require.GreaterOrEqual(t, len(summary.MCPConnect), 2) + for _, entry := range summary.MCPConnect { + require.Equal(t, "test-mcp", entry.Slug) + require.Equal(t, "connected", entry.Outcome) + } +} + func TestPlanModeRootChatApprovedExternalMCPToolInvocation(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index d8785da92ce43..22b6c20cf6b6a 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -35,6 +35,12 @@ import ( type generationPrepareInput struct { Chat database.Chat Messages []database.ChatMessage + // RecordMCPConnectSummaries receives the preparation's per-server + // MCP connect outcomes as soon as the connect phase completes. + // Preparation invokes it directly (rather than returning the + // outcomes) so attempts that fail after connecting still record + // before their error discards the prepared state. + RecordMCPConnectSummaries func([]mcpclient.ConnectSummary) } // generationPrepared contains the side-effect inputs for a generation task. @@ -96,10 +102,6 @@ type generationDebug struct { HistoryTipMessageID int64 TriggerLabel string ModelConfig database.ChatModelConfig - // MCPConnectSummaries carries per-server MCP connect outcomes - // from turn preparation into the debug run summary so slow or - // failing servers are visible in the debug UI. - MCPConnectSummaries []mcpclient.ConnectSummary } // generationOutcome describes a completed generation outcome. @@ -446,8 +448,9 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS } } prepareInput := generationPrepareInput{ - Chat: chat, - Messages: messages, + Chat: chat, + Messages: messages, + RecordMCPConnectSummaries: input.DebugTurn.RecordMCPConnectSummaries, } prepared, err := retryGenerationPhase(ctx, s, "prepare", func() (generationPrepared, error) { return s.server.prepareGeneration(ctx, prepareInput) @@ -459,11 +462,6 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS return s.finishGenerationError(ctx, machine, input, err, generationAttemptNotRequired) } cleanup := prepared.Cleanup - // Every preparation reconnects to MCP; record its connect - // outcomes as soon as preparation succeeds so exits that - // never reach the action dispatch (decision errors and - // actions that skip Ensure) still contribute to the run. - input.DebugTurn.RecordMCPConnectSummaries(prepared.Debug) var decision generationDecision if input.StopNudges.consume(stopNudgeKey(prepared.Messages)) { decision = generationDecision{kind: generationActionGenerateAssistant} diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index b6f1d26a07dcc..3d8a1153e5925 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -375,9 +375,16 @@ func (server *Server) prepareGeneration( return nil }) } - if err := g2.Wait(); err != nil { + g2Err := g2.Wait() + // Record connect outcomes before acting on any preparation error: + // ConnectAll has already run, so a failure below (or in g2 itself) + // would otherwise discard this attempt's outcomes. + if resolved.debugEnabled && input.RecordMCPConnectSummaries != nil && len(mcpSummaries) > 0 { + input.RecordMCPConnectSummaries(mcpSummaries) + } + if g2Err != nil { cleanup() - return generationPrepared{}, err + return generationPrepared{}, g2Err } if mcpCleanup != nil { @@ -662,7 +669,6 @@ func (server *Server) prepareGeneration( HistoryTipMessageID: historyTipMessageID, TriggerLabel: triggerLabel, ModelConfig: modelConfig, - MCPConnectSummaries: mcpSummaries, } } From 2b39852bd11b2063bdb2bc12702d3935aecf1e69 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:00:47 +0000 Subject: [PATCH 7/8] fix: create chat debug runs from recorded MCP outcomes and surface the dropped count --- coderd/x/chatd/active_turn_debug.go | 35 +++++-- .../chatd/active_turn_debug_internal_test.go | 48 +++++----- coderd/x/chatd/chatd_test.go | 94 +++++++++++++++++++ coderd/x/chatd/generation.go | 17 +++- coderd/x/chatd/generation_preparer.go | 47 +++++----- .../DebugPanel/DebugPanel.stories.tsx | 6 ++ .../RightPanel/DebugPanel/DebugRunCard.tsx | 7 ++ .../DebugPanel/debugPanelUtils.test.ts | 18 ++++ .../RightPanel/DebugPanel/debugPanelUtils.ts | 9 ++ 9 files changed, 222 insertions(+), 59 deletions(-) diff --git a/coderd/x/chatd/active_turn_debug.go b/coderd/x/chatd/active_turn_debug.go index 01c30d86910a2..d4ebf0c4b17e7 100644 --- a/coderd/x/chatd/active_turn_debug.go +++ b/coderd/x/chatd/active_turn_debug.go @@ -50,7 +50,14 @@ func (d *runnerDebugTurn) Ensure( d.mu.Lock() defer d.mu.Unlock() + return d.ensureLocked(ctx, chat, debug) +} +func (d *runnerDebugTurn) ensureLocked( + ctx context.Context, + chat database.Chat, + debug *generationDebug, +) context.Context { // Check finalized/disabled before created: once the turn is // finalized, new contexts must not be attributed to the // finalized run, even if it was created earlier. @@ -134,15 +141,22 @@ func (d *runnerDebugTurn) Context(ctx context.Context) context.Context { } // RecordMCPConnectSummaries merges one preparation's per-server MCP -// connect outcomes into the mcp_connect summary key. Preparation -// invokes it as soon as its MCP connect phase completes, so every -// attempt is recorded: preparations that fail after connecting, -// decision errors, and actions that never reach Ensure (local tool -// execution, requires-action, turn finishing). Outcomes recorded -// before the run exists are stashed and seeded into the run when -// Ensure creates it. -func (d *runnerDebugTurn) RecordMCPConnectSummaries(summaries []mcpclient.ConnectSummary) { - if d == nil { +// connect outcomes into the mcp_connect summary key, creating the +// debug run if it does not exist yet. Preparation invokes it as soon +// as its MCP connect phase completes, so every attempt is recorded: +// preparations that fail after connecting, decision errors, and +// actions that never reach Ensure (local tool execution, +// requires-action, turn finishing). Creating the run here matters +// when the first preparation connects and then fails: no model or +// compaction action ever runs Ensure, and Finalize discards the +// stash of a never-created run. +func (d *runnerDebugTurn) RecordMCPConnectSummaries( + ctx context.Context, + chat database.Chat, + debug *generationDebug, + summaries []mcpclient.ConnectSummary, +) { + if d == nil || len(summaries) == 0 { return } d.mu.Lock() @@ -150,7 +164,10 @@ func (d *runnerDebugTurn) RecordMCPConnectSummaries(summaries []mcpclient.Connec if d.disabled || d.finalized { return } + // Merge before ensuring so the outcomes ride the seed summary + // when this call is the one that creates the run. d.mergeMCPConnectSummariesLocked(summaries) + d.ensureLocked(ctx, chat, debug) } // maxMCPConnectSummaryEntries bounds the retained per-preparation MCP diff --git a/coderd/x/chatd/active_turn_debug_internal_test.go b/coderd/x/chatd/active_turn_debug_internal_test.go index 59ed5de4b9701..1a02b604577c1 100644 --- a/coderd/x/chatd/active_turn_debug_internal_test.go +++ b/coderd/x/chatd/active_turn_debug_internal_test.go @@ -215,15 +215,16 @@ func TestRunnerDebugTurnRecordMCPConnectSummaries(t *testing.T) { }} // Preparation records each attempt as soon as its connect phase - // completes, so the first outcome lands before Ensure creates - // the run and must be seeded into it. - turn.RecordMCPConnectSummaries(first) + // completes; the first record creates the run with the outcome + // already seeded, and a later Ensure must not create a second + // run. + turn.RecordMCPConnectSummaries(ctx, database.Chat{ID: chatID}, &debug, first) turn.Ensure(ctx, database.Chat{ID: chatID}, &debug) // A later generation step reconnects and reports a degraded // outcome for the same server; its action may never reach // Ensure, and the outcome must still survive to the finalized // summary. - turn.RecordMCPConnectSummaries(second) + turn.RecordMCPConnectSummaries(ctx, database.Chat{ID: chatID}, &debug, second) turn.RecordOutcome(chatdebug.StatusCompleted) turn.Finalize(ctx) @@ -278,8 +279,14 @@ func TestRunnerDebugTurnBoundsMCPConnectSummaries(t *testing.T) { return database.ChatDebugRun{ID: runID, ChatID: chatID}, nil }).Times(1) + debug := generationDebug{ + Enabled: true, + Service: svc, + TriggerMessageID: 1, + ModelConfig: database.ChatModelConfig{ID: uuid.New()}, + } // 25 preparations against 5 servers produce 125 outcomes, - // overflowing the cap before the run exists. + // overflowing the cap. The first record creates the run. for prep := 0; prep < 25; prep++ { batch := make([]mcpclient.ConnectSummary, 5) for server := range batch { @@ -290,32 +297,23 @@ func TestRunnerDebugTurnBoundsMCPConnectSummaries(t *testing.T) { DurationMS: int64(prep), } } - turn.RecordMCPConnectSummaries(batch) + turn.RecordMCPConnectSummaries(ctx, database.Chat{ID: chatID}, &debug, batch) } - debug := generationDebug{ - Enabled: true, - Service: svc, - TriggerMessageID: 1, - ModelConfig: database.ChatModelConfig{ID: uuid.New()}, - } - turn.Ensure(ctx, database.Chat{ID: chatID}, &debug) - type boundedSummary struct { MCPConnect []mcpclient.ConnectSummary `json:"mcp_connect"` MCPConnectDropped int `json:"mcp_connect_dropped"` } + // The run was created by the first record, so the seed carries + // only that preparation's outcomes. var seeded boundedSummary require.NoError(t, json.Unmarshal(seededSummary, &seeded)) - require.Len(t, seeded.MCPConnect, maxMCPConnectSummaryEntries) - require.Equal(t, 25, seeded.MCPConnectDropped) - // The newest outcomes win: the five oldest preparations were - // dropped, so the retained history starts at preparation 5. - require.Equal(t, int64(5), seeded.MCPConnect[0].DurationMS) - - // A preparation recorded after the run exists still respects - // the cap and grows the dropped count. - turn.RecordMCPConnectSummaries([]mcpclient.ConnectSummary{{ + require.Len(t, seeded.MCPConnect, 5) + require.Zero(t, seeded.MCPConnectDropped) + + // One more preparation still respects the cap and grows the + // dropped count. + turn.RecordMCPConnectSummaries(ctx, database.Chat{ID: chatID}, &debug, []mcpclient.ConnectSummary{{ ConfigID: uuid.New(), Slug: "server-0", Outcome: mcpclient.ConnectOutcomeTimeout, @@ -328,5 +326,9 @@ func TestRunnerDebugTurnBoundsMCPConnectSummaries(t *testing.T) { require.NoError(t, json.Unmarshal(finalSummary, &final)) require.Len(t, final.MCPConnect, maxMCPConnectSummaryEntries) require.Equal(t, 26, final.MCPConnectDropped) + // The newest outcomes win: the oldest five preparations were + // dropped, so the retained history starts at preparation 5 and + // ends with the timeout recorded above. + require.Equal(t, int64(5), final.MCPConnect[0].DurationMS) require.Equal(t, mcpclient.ConnectOutcomeTimeout, final.MCPConnect[maxMCPConnectSummaryEntries-1].Outcome) } diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 8dd69b11b7ed0..1e10b9c323293 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -11522,6 +11522,100 @@ func TestActiveServer_ChatTurnDebugRunRecordsMCPConnectOnPrepareError(t *testing } } +func TestActiveServer_ChatTurnDebugRunRecordsMCPConnectOnFirstPrepareError(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + mcpSrv := newTestMCPServer("test-mcp") + addTestMCPTextTool(mcpSrv, "echo", "Echoes the input", "echo: ") + mcpTS := httptest.NewServer(testMCPHTTPHandler(mcpSrv)) + t.Cleanup(mcpTS.Close) + + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + return chattest.OpenAIStreamingResponse( + chattest.OpenAITextChunks("Done!")..., + ) + }) + + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: org.ID, + DisplayName: "Test MCP", + Slug: "test-mcp", + Url: mcpTS.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + }) + + // Armed from the start: the very first preparation connects to + // the MCP server and then fails reading the compaction override, + // so no model or compaction action ever creates the debug run + // through Ensure. + var failOverrideReads atomic.Bool + failOverrideReads.Store(true) + server := newActiveTestServer(t, &overrideReadFailStore{Store: db, fail: &failOverrideReads}, ps, func(cfg *chatd.Config) { + withoutMCPToolSearch(cfg) + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.AlwaysEnableDebugLogs = true + }) + + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "mcp-connect-first-prepare-error-test", + ModelConfigID: model.ID, + MCPServerIDs: []uuid.UUID{mcpConfig.ID}, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("Say done."), + }, + }) + require.NoError(t, err) + + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusError) + require.NoError(t, server.Close()) + debugCtx := testutil.Context(t, testutil.WaitLong) + + var chatTurnRuns []database.ChatDebugRun + testutil.Eventually(debugCtx, t, func(ctx context.Context) bool { + runs, runsErr := db.GetChatDebugRunsByChatID(ctx, database.GetChatDebugRunsByChatIDParams{ + ChatID: chat.ID, + LimitVal: 100, + }) + if runsErr != nil { + return false + } + chatTurnRuns = chatTurnRuns[:0] + for _, run := range runs { + if run.Kind == string(codersdk.ChatDebugRunKindChatTurn) { + chatTurnRuns = append(chatTurnRuns, run) + } + } + return len(chatTurnRuns) == 1 && chatTurnRuns[0].FinishedAt.Valid + }, testutil.IntervalFast) + + require.Len(t, chatTurnRuns, 1) + var summary struct { + MCPConnect []struct { + Slug string `json:"slug"` + Outcome string `json:"outcome"` + } `json:"mcp_connect"` + } + require.NoError(t, json.Unmarshal(chatTurnRuns[0].Summary, &summary)) + // Every preparation fails after its connect phase, so the run + // exists only because recording the outcomes created it; it must + // carry at least the first attempt's connect outcome. + require.GreaterOrEqual(t, len(summary.MCPConnect), 1) + for _, entry := range summary.MCPConnect { + require.Equal(t, "test-mcp", entry.Slug) + require.Equal(t, "connected", entry.Outcome) + } +} + func TestPlanModeRootChatApprovedExternalMCPToolInvocation(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index 22b6c20cf6b6a..bff6f21480db1 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -36,11 +36,18 @@ type generationPrepareInput struct { Chat database.Chat Messages []database.ChatMessage // RecordMCPConnectSummaries receives the preparation's per-server - // MCP connect outcomes as soon as the connect phase completes. - // Preparation invokes it directly (rather than returning the - // outcomes) so attempts that fail after connecting still record - // before their error discards the prepared state. - RecordMCPConnectSummaries func([]mcpclient.ConnectSummary) + // MCP connect outcomes as soon as the connect phase completes, + // with the debug context needed to create the run when no action + // ever reaches Ensure. Preparation invokes it directly (rather + // than returning the outcomes) so attempts that fail after + // connecting still record before their error discards the + // prepared state. + RecordMCPConnectSummaries func( + ctx context.Context, + chat database.Chat, + debug *generationDebug, + summaries []mcpclient.ConnectSummary, + ) } // generationPrepared contains the side-effect inputs for a generation task. diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index 3d8a1153e5925..ff49843deb647 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -314,6 +314,29 @@ func (server *Server) prepareGeneration( } } + // Build the debug context before the connect phase so its + // outcomes can be recorded with run-creation context even when a + // later preparation step fails. + triggerMessageID, historyTipMessageID, triggerLabel := deriveChatDebugSeed(promptRows) + debugSvc := server.existingDebugService() + var debug *generationDebug + if resolved.debugEnabled { + if debugSvc == nil { + cleanup() + return generationPrepared{}, xerrors.New("chat debug service missing after enablement check") + } + debug = &generationDebug{ + Enabled: true, + Service: debugSvc, + Provider: resolved.resolvedProvider, + Model: resolved.resolvedModel, + TriggerMessageID: triggerMessageID, + HistoryTipMessageID: historyTipMessageID, + TriggerLabel: triggerLabel, + ModelConfig: modelConfig, + } + } + var g2 errgroup.Group g2.Go(func() error { var err error @@ -379,8 +402,8 @@ func (server *Server) prepareGeneration( // Record connect outcomes before acting on any preparation error: // ConnectAll has already run, so a failure below (or in g2 itself) // would otherwise discard this attempt's outcomes. - if resolved.debugEnabled && input.RecordMCPConnectSummaries != nil && len(mcpSummaries) > 0 { - input.RecordMCPConnectSummaries(mcpSummaries) + if debug != nil && input.RecordMCPConnectSummaries != nil && len(mcpSummaries) > 0 { + input.RecordMCPConnectSummaries(ctx, chat, debug, mcpSummaries) } if g2Err != nil { cleanup() @@ -652,26 +675,6 @@ func (server *Server) prepareGeneration( } } - triggerMessageID, historyTipMessageID, triggerLabel := deriveChatDebugSeed(promptRows) - debugSvc := server.existingDebugService() - var debug *generationDebug - if resolved.debugEnabled { - if debugSvc == nil { - cleanup() - return generationPrepared{}, xerrors.New("chat debug service missing after enablement check") - } - debug = &generationDebug{ - Enabled: true, - Service: debugSvc, - Provider: resolved.resolvedProvider, - Model: resolved.resolvedModel, - TriggerMessageID: triggerMessageID, - HistoryTipMessageID: historyTipMessageID, - TriggerLabel: triggerLabel, - ModelConfig: modelConfig, - } - } - compactionToolCallID := "chat_summarized_" + uuid.NewString() effectiveThreshold := modelConfig.CompressionThreshold if override, ok := server.resolveUserCompactionThreshold(ctx, chat.OwnerID, modelConfig.ID); ok { diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx index e59c896024a30..a9bfc81bff5bf 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx @@ -740,6 +740,9 @@ const mcpConnectSummary = { tool_count: 3, }, ], + // Entries beyond the retention cap; the card renders a + // truncation notice for them. + mcp_connect_dropped: 4, }; export const RunWithMCPConnectSummary: Story = { @@ -789,6 +792,9 @@ export const RunWithMCPConnectSummary: Story = { expect(mcp.getByText("connect: context deadline exceeded")).toBeVisible(); expect(mcp.getByText("45ms")).toBeVisible(); expect(mcp.getByText("3 tools")).toBeVisible(); + expect( + mcp.getByText("4 earlier connection samples omitted"), + ).toBeVisible(); }); }, }; diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx index b7d6facb4b706..492857e821ad3 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx @@ -271,6 +271,13 @@ export const DebugRunCard: FC = ({
  • ))}
+ {summaryVm.mcpConnectDropped > 0 ? ( +

+ {summaryVm.mcpConnectDropped} earlier connection{" "} + {summaryVm.mcpConnectDropped === 1 ? "sample" : "samples"}{" "} + omitted +

+ ) : null} ) : null} {steps.map((step) => ( diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts index 5d9684d7bf637..b663aa7bef981 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts @@ -827,6 +827,7 @@ describe("coerceRunSummary", () => { totalInputTokens: 120, totalOutputTokens: 45, mcpConnect: [], + mcpConnectDropped: 0, warnings: [], }); }); @@ -853,6 +854,7 @@ describe("coerceRunSummary", () => { totalInputTokens: undefined, totalOutputTokens: undefined, mcpConnect: [], + mcpConnectDropped: 0, warnings: [], }); }); @@ -899,6 +901,22 @@ describe("coerceRunSummary", () => { expect(coerceRunSummary({ mcp_connect: "oops" }).mcpConnect).toEqual([]); }); + it("coerces the dropped MCP connect sample count", () => { + expect( + coerceRunSummary({ mcp_connect_dropped: 25 }).mcpConnectDropped, + ).toBe(25); + expect(coerceRunSummary({ mcpConnectDropped: "7" }).mcpConnectDropped).toBe( + 7, + ); + expect( + coerceRunSummary({ mcp_connect_dropped: -3 }).mcpConnectDropped, + ).toBe(0); + expect( + coerceRunSummary({ mcp_connect_dropped: "oops" }).mcpConnectDropped, + ).toBe(0); + expect(coerceRunSummary({}).mcpConnectDropped).toBe(0); + }); + it("unwraps JSON-string payloads before coercing", () => { const summary = coerceRunSummary( JSON.stringify({ diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts index 39dd375bba076..da532e32841c7 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts @@ -453,6 +453,7 @@ interface RunSummaryViewModel { totalInputTokens: number | undefined; totalOutputTokens: number | undefined; mcpConnect: MCPConnectSummaryViewModel[]; + mcpConnectDropped: number; warnings: string[]; } @@ -913,6 +914,7 @@ export const coerceRunSummary = (data: unknown): RunSummaryViewModel => { totalInputTokens: undefined, totalOutputTokens: undefined, mcpConnect: [], + mcpConnectDropped: 0, warnings: [], }; const parsed = deepParse(data); @@ -928,6 +930,9 @@ export const coerceRunSummary = (data: unknown): RunSummaryViewModel => { "primaryLabel", ), ); + const mcpConnectDropped = toFiniteNumber( + pickField(parsed, "mcp_connect_dropped", "mcpConnectDropped"), + ); return { primaryLabel: firstMessage ?? "", endpointLabel: toOptionalString( @@ -963,6 +968,10 @@ export const coerceRunSummary = (data: unknown): RunSummaryViewModel => { mcpConnect: coerceMCPConnectSummaries( pickField(parsed, "mcp_connect", "mcpConnect"), ), + mcpConnectDropped: + mcpConnectDropped !== undefined && mcpConnectDropped > 0 + ? Math.floor(mcpConnectDropped) + : 0, warnings: [], }; }; From 7bb3c7297a52579e272bae1bbe648634c00e0e5a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:15:00 +0000 Subject: [PATCH 8/8] fix(coderd/x/chatd/mcpclient): bound persisted MCP connect error size --- coderd/x/chatd/mcpclient/export_test.go | 7 +++++ coderd/x/chatd/mcpclient/mcpclient.go | 31 ++++++++++++++++++--- coderd/x/chatd/mcpclient/mcpclient_test.go | 32 ++++++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/coderd/x/chatd/mcpclient/export_test.go b/coderd/x/chatd/mcpclient/export_test.go index d5ec3bb687111..53c3bf0d10aa0 100644 --- a/coderd/x/chatd/mcpclient/export_test.go +++ b/coderd/x/chatd/mcpclient/export_test.go @@ -34,3 +34,10 @@ func ConnectAllForTest( // BuildAuthHeadersForTest exposes buildAuthHeaders for external // tests. var BuildAuthHeadersForTest = buildAuthHeaders + +// SummaryErrorForTest exposes summaryError for external tests. +var SummaryErrorForTest = summaryError + +// MaxSummaryErrorLenForTest exposes the persisted-error byte cap for +// external tests. +const MaxSummaryErrorLenForTest = maxSummaryErrorLen diff --git a/coderd/x/chatd/mcpclient/mcpclient.go b/coderd/x/chatd/mcpclient/mcpclient.go index f6538f9c07b48..4eb90dda904a6 100644 --- a/coderd/x/chatd/mcpclient/mcpclient.go +++ b/coderd/x/chatd/mcpclient/mcpclient.go @@ -14,6 +14,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "charm.land/fantasy" "github.com/google/uuid" @@ -93,8 +94,8 @@ type ConnectSummary struct { Outcome ConnectOutcome `json:"outcome"` DurationMS int64 `json:"duration_ms"` ToolCount int `json:"tool_count,omitempty"` - // Error is the redacted connect error, present unless the - // outcome is connected or no_tools. + // Error is the redacted, size-bounded connect error, present + // unless the outcome is connected or no_tools. Error string `json:"error,omitempty"` } @@ -207,10 +208,10 @@ func connectAllWithHooks( switch { case connectErr != nil && errors.Is(connectErr, context.DeadlineExceeded): summary.Outcome = ConnectOutcomeTimeout - summary.Error = redactErrorURL(connectErr) + summary.Error = summaryError(connectErr) case connectErr != nil: summary.Outcome = ConnectOutcomeError - summary.Error = redactErrorURL(connectErr) + summary.Error = summaryError(connectErr) case len(serverTools) == 0: summary.Outcome = ConnectOutcomeNoTools default: @@ -661,6 +662,28 @@ func redactErrorURL(err error) string { return err.Error() } +// maxSummaryErrorLen bounds the persisted connect error in bytes. +// Protocol errors can embed arbitrarily large remote-controlled +// response bodies, so without this cap a single retained summary +// could inflate the JSONB row and every debug-runs payload +// regardless of the entry-count cap. +const maxSummaryErrorLen = 512 + +// summaryError renders a connect error for the persisted summary: +// credential-bearing URLs are redacted and the result is truncated +// to maxSummaryErrorLen bytes on a rune boundary. +func summaryError(err error) string { + msg := redactErrorURL(err) + if len(msg) <= maxSummaryErrorLen { + return msg + } + cut := maxSummaryErrorLen + for cut > 0 && !utf8.RuneStart(msg[cut]) { + cut-- + } + return msg[:cut] + "... (truncated)" +} + // MCPToolIdentifier is implemented by tools that originate from // an MCP server config and can report the config's database ID. type MCPToolIdentifier interface { diff --git a/coderd/x/chatd/mcpclient/mcpclient_test.go b/coderd/x/chatd/mcpclient/mcpclient_test.go index 82434a9ace5e8..bd9a9b2643bbe 100644 --- a/coderd/x/chatd/mcpclient/mcpclient_test.go +++ b/coderd/x/chatd/mcpclient/mcpclient_test.go @@ -17,6 +17,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/xerrors" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" @@ -986,6 +987,37 @@ func TestRedactURL(t *testing.T) { } } +func TestSummaryErrorTruncation(t *testing.T) { + t.Parallel() + + t.Run("short errors pass through", func(t *testing.T) { + t.Parallel() + got := mcpclient.SummaryErrorForTest(xerrors.New("connect refused")) + require.Equal(t, "connect refused", got) + }) + + t.Run("large remote-controlled errors are bounded", func(t *testing.T) { + t.Parallel() + huge := strings.Repeat("x", 1<<20) + got := mcpclient.SummaryErrorForTest(xerrors.New(huge)) + require.Len(t, got, mcpclient.MaxSummaryErrorLenForTest+len("... (truncated)")) + require.True(t, strings.HasSuffix(got, "... (truncated)")) + require.True(t, strings.HasPrefix(got, "xxx")) + }) + + t.Run("truncation lands on a rune boundary", func(t *testing.T) { + t.Parallel() + // A two-byte rune straddles the cap boundary, so the cut + // must back up instead of splitting it. + msg := strings.Repeat("a", mcpclient.MaxSummaryErrorLenForTest-1) + + strings.Repeat("é", 20) + got := mcpclient.SummaryErrorForTest(xerrors.New(msg)) + require.True(t, utf8.ValidString(got)) + require.True(t, strings.HasSuffix(got, "... (truncated)")) + require.Len(t, got, mcpclient.MaxSummaryErrorLenForTest-1+len("... (truncated)")) + }) +} + func TestConnectAll_ExpiredToken(t *testing.T) { t.Parallel() ctx := context.Background()