From 69ef5ff1e2b505a4f9d04e1723a1a7ea22d66294 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:32:22 -0400 Subject: [PATCH 1/2] Pin Lite's MCP read tools under the #4198 response budget Part of #4198. Mirrors Darling's McpReadToolBudgetLiveTests: reflects over every [McpServerTool] in the Lite service assembly, excludes analyze_*/ compare_*/audit_config/mute_analysis_finding, binds each remaining tool's parameters generically and asserts the reply stays under McpResponseBudget.DefaultBytes on one busy DuckDB-only fixture. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- Lite.Tests/McpReadToolBudgetTests.cs | 431 +++++++++++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 Lite.Tests/McpReadToolBudgetTests.cs diff --git a/Lite.Tests/McpReadToolBudgetTests.cs b/Lite.Tests/McpReadToolBudgetTests.cs new file mode 100644 index 000000000..0895fab0a --- /dev/null +++ b/Lite.Tests/McpReadToolBudgetTests.cs @@ -0,0 +1,431 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using ModelContextProtocol.Server; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitorLite.Analysis; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Mcp; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// #4198 part 2, Lite's twin of Darling's Darling.Tests.McpReadToolBudgetLiveTests. Enumerates every +/// [McpServerTool] method on every [McpServerToolType] class in the Lite service assembly BY +/// REFLECTION () so a brand-new tool inherits this test with no line changing here, +/// excludes the same surface #4198 excluded on Darling (write tools, analyze_*, compare_*, +/// audit_config -- see and ), binds each remaining +/// tool's parameters generically (: its DI services, server_name at the seeded +/// server, everything else at its declared default) and asserts the UTF-8 reply is under +/// -- unless the tool is named in , +/// today's #4198 backlog. A fix lands by deleting its row: an exempt tool that now fits fails this test until +/// its row is gone, and a tool that is NOT exempt and goes over fails it too. +/// +/// Why a roster, not a heuristic, for write tools. Lite serves 89 MCP tools and none of their +/// names is add_servers/create_*/update_*/delete_*/remove_server/set_*_enabled -- Lite has no server-onboarding, +/// custom-view, custom-alert-rule or notification-route MCP surface at all. Checked against every tool name in +/// the assembly (not just a grep for a mutating verb): the only tool outside get_*/list_servers/ +/// analyze_*/compare_*/audit_config is mute_analysis_finding, so it is the whole +/// write surface. stays an exact, hand-maintained list regardless, so a new write +/// tool has to be added to it on purpose, or this test will actually invoke it. +/// +/// The fixture is DuckDB only -- no live rig, no Postgres, no fleet simulation (Lite has no +/// get_fleet_overview). One busy server carries query-store regressions with ~4 KB of text each +/// (baseline interval past the default 24h recent window, then a stepped-up regressed interval -- the #4198 +/// field offender and Darling's #1 measured tool, same shape QueryStoreRegressionsBudgetTests already +/// proves against this schema), 30 multi-process deadlock graphs, 30 blocked-process reports with report XML, +/// 10 DMV-sourced blocking snapshots, every SQL Server collector's worth of collection-log history with mixed +/// SUCCESS/ERROR rows so nothing compacts away, plan-correction recommendations across several databases, and +/// a page of long query completions -- the same seven tables Darling's twin seeds, using the exact column +/// lists McpPageContractTests, QueryStoreRegressionsBudgetTests and QueryStoreTopBudgetTests +/// already prove against Lite's DuckDB schema (generated from the same engine-neutral collector catalog +/// Darling's Postgres schema is, per DuckDbSchemaGenerator's own remarks). +/// +/// Known gaps. A tool whose required parameter has no default and is not one of the DI services +/// below gets a placeholder (0/false/null) instead -- it likely answers empty/not-found and passes trivially, +/// because this fixture does not seed its backing rows/state. Object-level stats (get_index_usage/ +/// get_object_locking, backed by index_object_stats), DMV query stats (get_query_heatmap/ +/// get_top_queries_by_cpu/get_top_procedures_by_cpu, backed by query_stats), analysis +/// findings (get_analysis_findings), and every single-row drill-down (get_plan_xml, ...) are in +/// this bucket, matching Darling's twin's own gaps for the same tables; each run's GAPS output (and the PR +/// body) names exactly which. +/// +public sealed class McpReadToolBudgetTests : IClassFixture, IDisposable +{ + private const string ServerName = "mcp-read-tool-budget-4198"; + + private const int DbCount = 4; + private const int QueriesPerDb = 10; + private const int DeadlockRows = 30; + private const int BlockedRows = 30; + private const int DmvBlockingRows = 10; + private const int CollectionLogSamplesPerCollector = 10; + private const int PlanCorrectionRows = 30; + private const int LongQueryRows = 20; + + private readonly DuckDbInitializer _duckDb; + private readonly string _configDir; + private readonly ServerManager _serverManager; + private readonly LocalDataService _dataService; + private readonly int _serverId; + private long _nextId = -1; + private DuckDBConnection? _seedConn; + + public McpReadToolBudgetTests(SharedDuckDbFixture fixture) + { + fixture.ResetData(); + _duckDb = fixture.DuckDb; + + _configDir = Path.Combine(Path.GetTempPath(), "pmlite-mcpbudget-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_configDir); + _serverManager = new ServerManager(_configDir); + + var server = new ServerConnection { ServerName = ServerName, DisplayName = ServerName }; + _serverManager.AddServer(server); + _serverId = RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(server)); + + _dataService = new LocalDataService(_duckDb); + } + + public void Dispose() + { + _seedConn?.Dispose(); + try { if (Directory.Exists(_configDir)) Directory.Delete(_configDir, recursive: true); } + catch (IOException) { /* best-effort cleanup */ } + catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + } + + /// + /// The write surface #4198 excludes from its measurement, same reasoning as Darling's twin: invoking a + /// write tool during a "measure every read tool" pass would mutate the fixture instead of just reading it. + /// See the class remarks for how this was confirmed to be the whole write surface. + /// + private static readonly HashSet WriteToolRoster = new(StringComparer.Ordinal) + { + "mute_analysis_finding", + }; + + /// + /// #4198's per-tool backlog, measured on THIS fixture: name -> bytes at the time the row was added. A + /// row is removed by whichever lane fixes that tool's defaults; the test fails if a listed tool now fits + /// (a stale exemption hiding a real fix) as loudly as it fails for a new, un-exempted offender. + /// + private static readonly Dictionary ExemptOffenders = new(StringComparer.Ordinal) + { + /* Same gap as Darling's twin (#4268, still open): compaction only fires on collectors with nothing to + report, and this fixture's collectors are deliberately unhealthy (mixed SUCCESS/ERROR), so nothing + compacts and the reply stays over. Measured on this fixture at 43,115 B. */ + ["get_collection_health"] = 43_115, // #4198, #4268 + }; + + [Fact] + public async Task EveryReadToolAnswersUnderTheDefaultBudget_UnlessListedAsA4198Offender() + { + await SeedAsync(); + + var analysisService = new AnalysisService(_duckDb) { MinimumDataHours = 0 }; + var catalog = new McpToolGuideCatalog(); + + var measured = new List<(string Name, int Bytes)>(); + var gaps = new List(); + + foreach (var (name, method) in ReadToolMethods()) + { + var args = BindArgs(method, _dataService, _serverManager, analysisService, catalog, gaps, name); + string json; + try + { + json = await InvokeAsync(method, args); + } + catch (Exception ex) + { + var inner = (ex as TargetInvocationException)?.InnerException ?? ex; + gaps.Add($"{name}: threw {inner.GetType().Name}: {inner.Message.Split('\n')[0]}"); + continue; + } + + measured.Add((name, Encoding.UTF8.GetByteCount(json))); + } + + var report = string.Join(Environment.NewLine, measured.OrderByDescending(m => m.Bytes).Select(m => + $"{m.Name}: {m.Bytes:N0} B{(ExemptOffenders.ContainsKey(m.Name) ? " (exempt, #4198 per-tool lane)" : m.Bytes > McpResponseBudget.DefaultBytes ? " OVER" : "")}")); + TestContext.Current.TestOutputHelper?.WriteLine(report); + if (gaps.Count > 0) + TestContext.Current.TestOutputHelper?.WriteLine("GAPS (no usable default; passes trivially):" + Environment.NewLine + string.Join(Environment.NewLine, gaps)); + + var overBudget = measured + .Where(m => m.Bytes > McpResponseBudget.DefaultBytes && !ExemptOffenders.ContainsKey(m.Name)) + .Select(m => $"{m.Name}: {m.Bytes:N0} B") + .ToArray(); + var staleExemptions = ExemptOffenders.Keys + .Where(name => measured.Any(m => m.Name == name && m.Bytes <= McpResponseBudget.DefaultBytes)) + .ToArray(); + var missingExemptTools = ExemptOffenders.Keys.Where(name => measured.All(m => m.Name != name)).ToArray(); + + /* 89 tools total, minus mute_analysis_finding, five analyze_*, compare_analysis and audit_config = 81 + read tools attempted; a tool with no usable default throws and lands in gaps, not measured, so the + floor is set under 81 rather than at it. */ + Assert.True(measured.Count > 60, $"only {measured.Count} read tools were measured -- reflection likely under-enumerated the service assembly"); + Assert.True(overBudget.Length == 0, + "over the #4198 default budget and not in ExemptOffenders:" + Environment.NewLine + string.Join(Environment.NewLine, overBudget) + + Environment.NewLine + "GAPS:" + Environment.NewLine + string.Join(Environment.NewLine, gaps)); + Assert.True(staleExemptions.Length == 0, + "exempt tools that now fit the budget -- remove their ExemptOffenders row:" + Environment.NewLine + string.Join(Environment.NewLine, staleExemptions)); + Assert.True(missingExemptTools.Length == 0, + "ExemptOffenders names a tool that was not measured (renamed, or newly excluded):" + Environment.NewLine + string.Join(Environment.NewLine, missingExemptTools)); + + var allNames = AllToolMethods().Select(x => x.Name).ToHashSet(StringComparer.Ordinal); + Assert.All(WriteToolRoster, w => Assert.Contains(w, allNames)); + } + + /* ═══════════════════════════ reflection: enumerate, exclude, bind, invoke ═══════════════════════════ */ + + /// Every [McpServerTool] method on every [McpServerToolType] class in the Lite + /// service assembly -- read AND write, so can be sanity-checked against it. + private static (string Name, MethodInfo Method)[] AllToolMethods() + { + Type[] types; + try + { + types = typeof(McpQueryTools).Assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + Assert.Fail("the service assembly did not fully load, so a missing tool would look like a passing pin: " + + string.Join("; ", ex.LoaderExceptions.Where(e => e is not null).Select(e => e!.Message).Distinct())); + return Array.Empty<(string, MethodInfo)>(); + } + + return types + .Where(t => t.GetCustomAttribute() is not null) + .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.Static)) + .Select(m => (Attr: m.GetCustomAttribute(), Method: m)) + .Where(x => x.Attr is not null) + .Select(x => (Name: x.Attr!.Name!, Method: x.Method)) + .OrderBy(x => x.Name, StringComparer.Ordinal) + .ToArray(); + } + + private static bool IsExcluded(string name) => + name.StartsWith("analyze_", StringComparison.Ordinal) || + name.StartsWith("compare_", StringComparison.Ordinal) || + name == "audit_config" || + WriteToolRoster.Contains(name); + + private static (string Name, MethodInfo Method)[] ReadToolMethods() => + AllToolMethods().Where(x => !IsExcluded(x.Name)).ToArray(); + + /// Binds one tool's parameters: its DI services, server_name at the seeded server, every + /// other optional parameter at its declared default. A required parameter that is none of those gets a + /// placeholder and a note -- see the class remarks' "Known gaps". + private static object?[] BindArgs(MethodInfo method, LocalDataService dataService, ServerManager serverManager, + AnalysisService analysisService, McpToolGuideCatalog catalog, List gaps, string toolName) + { + var parameters = method.GetParameters(); + var args = new object?[parameters.Length]; + for (var i = 0; i < parameters.Length; i++) + { + var p = parameters[i]; + if (p.ParameterType == typeof(LocalDataService)) { args[i] = dataService; continue; } + if (p.ParameterType == typeof(ServerManager)) { args[i] = serverManager; continue; } + if (p.ParameterType == typeof(AnalysisService)) { args[i] = analysisService; continue; } + if (p.ParameterType == typeof(McpToolGuideCatalog)) { args[i] = catalog; continue; } + if (p.Name == "server_name") { args[i] = ServerName; continue; } + if (p.HasDefaultValue) { args[i] = p.DefaultValue; continue; } + + gaps.Add($"{toolName}: '{p.Name}' ({p.ParameterType.Name}) has no default and is not a known DI service -- placeholder passed, likely answers empty/invalid."); + args[i] = p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null; + } + return args; + } + + /// Every MCP tool method returns either string (get_tool_guide) or Task<string> + /// (everything else); this is the reply an MCP client would receive either way. + private static async Task InvokeAsync(MethodInfo method, object?[] args) + { + var result = method.Invoke(null, args); + return result switch + { + Task t => await t, + string s => s, + _ => throw new InvalidOperationException($"{method.Name} returned {(result?.GetType().Name ?? "null")}, expected string or Task"), + }; + } + + /* ═══════════════════════════ the fixture: one busy server, DuckDB only ═══════════════════════════ */ + + private static string PadBlock(int approxChars) + { + const string Chunk = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_"; + var sb = new StringBuilder(approxChars + Chunk.Length); + while (sb.Length < approxChars) sb.Append(Chunk); + return sb.ToString(); + } + + private static DateTime Naive(DateTime utc) => DateTime.SpecifyKind(utc, DateTimeKind.Unspecified); + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private async Task SeedAsync() + { + var now = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-1)); + + /* query_store_stats: DbCount tenant databases x QueriesPerDb queries, a baseline interval (40h back) + and a regressed recent interval (30m back, duration/CPU stepped up), carrying ~3.8 KB of + parameterized query text -- the #4198 field offender and Darling's #1 measured tool. + get_query_store_regressions' "baseline" is everything collected BEFORE the default 24h recent + window, so the baseline capture has to sit past that cutoff or the comparison sees no baseline at + all (QueryStoreRegressionsBudgetTests' proven shape). */ + for (var db = 0; db < DbCount; db++) + { + var dbName = $"TenantDb{db:00}"; + for (var q = 0; q < QueriesPerDb; q++) + { + var queryId = 1000L + (db * 100) + q; + var text = $"EXEC dbo.usp_ProcessOrder_{q} @OrderId = {5000 + q}, @TenantId = {db}, @Payload = N'" + PadBlock(3800) + "'"; + await SeedQueryStoreAsync(now.AddHours(-40), executions: 50, avgDurationUs: 1_000, avgCpuUs: 1_000, intervalId: 2 * (db * 100 + q) + 1, queryId, dbName, "SELECT 1"); + await SeedQueryStoreAsync(now.AddMinutes(-30), executions: 480, avgDurationUs: 55_000, avgCpuUs: 48_000, intervalId: 2 * (db * 100 + q) + 2, queryId, dbName, text); + } + } + + /* deadlocks: multi-process graphs the width of a real one. */ + for (var i = 0; i < DeadlockRows; i++) + { + var t = now.AddMinutes(-2 * i); + var db = $"TenantDb{i % DbCount:00}"; + var xml = "" + + "" + + "UPDATE dbo.Orders SET Status = @NewStatus WHERE OrderId = @OrderId" + + "" + PadBlock(1200) + "" + + "" + + "SELECT * FROM dbo.Orders WHERE OrderId = @OrderId" + + "" + PadBlock(1200) + ""; + await ExecAsync( + "INSERT INTO deadlocks (deadlock_id, collection_time, server_id, server_name, deadlock_time, victim_process_id, victim_sql_text, deadlock_graph_xml) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", + _nextId--, Naive(t), _serverId, ServerName, Naive(t), "process" + i + "a", + "UPDATE dbo.Orders SET Status = @NewStatus WHERE OrderId = @OrderId", xml); + } + + /* blocked_process_reports: report XML the width of a real one. */ + for (var i = 0; i < BlockedRows; i++) + { + var t = now.AddMinutes(-90).AddSeconds(-3 * i); + var db = $"TenantDb{i % DbCount:00}"; + var xml = "" + + "" + PadBlock(1000) + "" + + "" + PadBlock(1000) + ""; + await ExecAsync( + @"INSERT INTO blocked_process_reports + (blocked_report_id, collection_time, server_id, server_name, event_time, database_name, + blocked_spid, blocking_spid, wait_time_ms, lock_mode, blocked_sql_text, blocking_sql_text, blocked_process_report_xml, contentious_object) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)", + _nextId--, Naive(t), _serverId, ServerName, Naive(t), db, 50 + i, 150 + i, 4000L + i, "X", + "UPDATE dbo.Orders SET Status = @NewStatus WHERE OrderId = @OrderId", "SELECT * FROM dbo.Orders WHERE OrderId = @OrderId", xml, db + ".dbo.Orders"); + } + + /* dmv_blocking_snapshots: the DMV-sourced arm beside the XE one, the merged population + get_blocked_process_reports reads alongside blocked_process_reports. */ + for (var i = 0; i < DmvBlockingRows; i++) + { + var t = now.AddMinutes(-95).AddSeconds(-2 * i); + var db = $"TenantDb{i % DbCount:00}"; + await ExecAsync( + @"INSERT INTO dmv_blocking_snapshots + (collection_id, collection_time, server_id, server_name, monitor_loop, event_time, database_name, + blocked_spid, blocking_spid, wait_time_ms, lock_mode, blocking_status, contentious_object, blocked_sql_text, blocking_sql_text) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)", + _nextId--, Naive(t), _serverId, ServerName, -1, Naive(t), db, 200 + i, 300 + i, 2500L, "S", "suspended", + db + ".dbo.Inventory", "SELECT * FROM dbo.Inventory WHERE Sku = @Sku", "WAITFOR DELAY '00:00:05'"); + } + + /* collection_log: every SQL Server collector, mixed SUCCESS/ERROR so nothing compacts away -- matching + Darling's #4268 note that get_collection_health only compacts collectors with nothing to report. */ + var collectors = CollectorCatalog.All.Where(d => d.TargetEngine == CollectorTargetEngine.SqlServer).Select(d => d.Name).ToArray(); + foreach (var collector in collectors) + { + for (var s = 0; s < CollectionLogSamplesPerCollector; s++) + { + var t = now.AddMinutes(-5 * s); + await ExecAsync( + @"INSERT INTO collection_log + (log_id, server_id, server_name, collector_name, collection_time, + duration_ms, status, error_message, rows_collected, sql_duration_ms, duckdb_duration_ms) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", + _nextId--, _serverId, ServerName, collector, Naive(t), 100.0 + s, s % 6 == 0 ? "ERROR" : "SUCCESS", + s % 6 == 0 ? "Login failed for user 'darling_monitor'." : null, s % 6 == 0 ? (int?)null : 50 + s, 80.0, 20.0); + } + } + + /* plan_correction: recommendations across every tenant database. */ + for (var i = 0; i < PlanCorrectionRows; i++) + { + var t = now.AddMinutes(-4 * i); + var db = $"TenantDb{i % DbCount:00}"; + await ExecAsync( + @"INSERT INTO plan_correction (collection_id, collection_time, server_id, server_name, database_name, recommendation_name, recommendation_state, score) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", + _nextId--, Naive(t), _serverId, ServerName, db, "PR_" + (i % 6), i % 3 == 0 ? "Active" : "Superseded", 40 + i); + } + + /* long_query_completions: a page of slow statements with real text width. */ + for (var i = 0; i < LongQueryRows; i++) + { + var t = now.AddMinutes(-6 * i); + var db = $"TenantDb{i % DbCount:00}"; + await ExecAsync( + @"INSERT INTO long_query_completions (long_query_completion_id, collection_time, server_id, server_name, event_time, event_type, database_name, duration_microseconds, statement_text) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", + _nextId--, Naive(t), _serverId, ServerName, Naive(t), "sql_batch_completed", db, + (long)(i + 1) * 500_000, "EXEC dbo.usp_NightlyReconcile_" + i + " " + PadBlock(400)); + } + } + + private Task SeedQueryStoreAsync(DateTime collectionTime, long executions, long avgDurationUs, long avgCpuUs, long intervalId, long queryId, string dbName, string queryText) => + ExecAsync(@" +INSERT INTO query_store_stats + (collection_id, collection_time, server_id, server_name, database_name, query_id, plan_id, + execution_type_desc, execution_count, avg_duration_us, avg_cpu_time_us, avg_logical_io_reads, + runtime_stats_interval_id, query_text, last_execution_time) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)", + _nextId--, Naive(collectionTime), _serverId, ServerName, dbName, queryId, 9L, "Regular", + executions, avgDurationUs, avgCpuUs, 100L, intervalId, queryText, Naive(collectionTime)); + + private async Task SeedConnectionAsync() + { + if (_seedConn is null) + { + _seedConn = _duckDb.CreateConnection(); + await _seedConn.OpenAsync(); + } + return _seedConn; + } + + private async Task ExecAsync(string sql, params object?[] values) + { + using var readLock = _duckDb.AcquireReadLock(); + var conn = await SeedConnectionAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + foreach (var v in values) + cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); + await cmd.ExecuteNonQueryAsync(); + } +} From 8e0c62146352e52340fb3b78d888576f8fb584c1 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:36:34 -0400 Subject: [PATCH 2/2] Lite budget pin: #4268 has merged; the row stays open under #4198 The exempt row's comment said "#4268, still open", but #4268 merged. Word it like Darling's twin row: #4268 compacts only healthy collectors, this fixture's are not, so the tool stays over and open under #4198. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ --- Lite.Tests/McpReadToolBudgetTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lite.Tests/McpReadToolBudgetTests.cs b/Lite.Tests/McpReadToolBudgetTests.cs index 0895fab0a..dec1c33b8 100644 --- a/Lite.Tests/McpReadToolBudgetTests.cs +++ b/Lite.Tests/McpReadToolBudgetTests.cs @@ -129,10 +129,10 @@ public void Dispose() /// private static readonly Dictionary ExemptOffenders = new(StringComparer.Ordinal) { - /* Same gap as Darling's twin (#4268, still open): compaction only fires on collectors with nothing to - report, and this fixture's collectors are deliberately unhealthy (mixed SUCCESS/ERROR), so nothing - compacts and the reply stays over. Measured on this fixture at 43,115 B. */ - ["get_collection_health"] = 43_115, // #4198, #4268 + /* #4268 (merged) compacts only HEALTHY collectors with nothing to report; this fixture's collectors are + deliberately not healthy (mixed SUCCESS/ERROR), so nothing compacts and the reply is still over. + Measured on this fixture at 43,115 B. Stays open under #4198, like Darling's twin row. */ + ["get_collection_health"] = 43_115, // #4198 }; [Fact]