diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs index efe28849b..e784beeef 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs @@ -144,10 +144,6 @@ public static class DarlingWebEndpoints "get_mute_rules", "get_notification_routes", "get_sweep_reports", - "get_database_config_changes", - "get_database_scoped_config", - "get_server_config_changes", - "get_trace_flag_changes", "get_collection_health", "get_collection_log", "get_current_waits_trend", @@ -218,11 +214,9 @@ public static class DarlingWebEndpoints "get_memory_grants", "get_memory_pressure_events", "get_resource_semaphore", - "get_database_sizes", + "get_pvs_stats", - "get_index_usage", - "get_object_locking", - "get_table_index_sizes", + "get_cpu_scheduler_pressure", "get_plan_cache_bloat", "get_running_jobs", @@ -2986,11 +2980,11 @@ budget cut does not silently shrink what the viewer renders. */ ["get_database_config"] = (c, pg, an) => DarlingMcpConfigTools.GetDatabaseConfig(pg, Server(c), Str(c, "database_name"), c.RequestAborted), ["get_server_config"] = (c, pg, an) => DarlingMcpConfigTools.GetServerConfig(pg, Server(c), c.RequestAborted), ["get_trace_flags"] = (c, pg, an) => DarlingMcpConfigTools.GetTraceFlags(pg, Server(c), c.RequestAborted), - ["get_database_config_changes"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetDatabaseConfigChanges(pg, Server(c), Hours(c, 168), as_of: AsOf(c)), - ["get_database_scoped_config"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetDatabaseScopedConfig(pg, Server(c), Str(c, "database_name")), + ["get_database_config_changes"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetDatabaseConfigChanges(pg, Server(c), Hours(c, 168), as_of: AsOf(c), cancellationToken: c.RequestAborted), + ["get_database_scoped_config"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetDatabaseScopedConfig(pg, Server(c), Str(c, "database_name"), cancellationToken: c.RequestAborted), ["get_query_store_health"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetQueryStoreHealth(pg, Server(c), Str(c, "database_name"), c.RequestAborted), - ["get_server_config_changes"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetServerConfigChanges(pg, Server(c), Hours(c, 168), as_of: AsOf(c)), - ["get_trace_flag_changes"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetTraceFlagChanges(pg, Server(c), Hours(c, 168), as_of: AsOf(c)), + ["get_server_config_changes"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetServerConfigChanges(pg, Server(c), Hours(c, 168), as_of: AsOf(c), cancellationToken: c.RequestAborted), + ["get_trace_flag_changes"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetTraceFlagChanges(pg, Server(c), Hours(c, 168), as_of: AsOf(c), cancellationToken: c.RequestAborted), /* ── core data reads ── */ /* #4198: full_detail=true keeps the web viewer's payload exactly what it was before the default @@ -3147,9 +3141,9 @@ sizing the points itself (the OptionalDouble rule). */ ["get_resource_semaphore"] = (c, pg, an) => DarlingMcpMemoryGrantTools.GetResourceSemaphore(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), /* ── object / index stats ── */ - ["get_database_sizes"] = (c, pg, an) => DarlingMcpObjectStatsTools.GetDatabaseSizes(pg, Server(c)), + ["get_database_sizes"] = (c, pg, an) => DarlingMcpObjectStatsTools.GetDatabaseSizes(pg, Server(c), cancellationToken: c.RequestAborted), ["get_pvs_stats"] = (c, pg, an) => DarlingMcpPvsTools.GetPvsStats(pg, Server(c), QueryInt(c, "trend_hours_back", null, 0)), - ["get_index_usage"] = (c, pg, an) => DarlingMcpObjectStatsTools.GetIndexUsage(pg, Server(c), Str(c, "database_name"), Rows(c, "limit", 200)), + ["get_index_usage"] = (c, pg, an) => DarlingMcpObjectStatsTools.GetIndexUsage(pg, Server(c), Str(c, "database_name"), Rows(c, "limit", 200), cancellationToken: c.RequestAborted), /* #4258: limit defaults to 75 on the MCP signature now (was an uncapped-looking 200-row hard fetch with no parameter at all), sized under the shared response budget. The web viewer has always effectively received that old 200-row fetch (there was no smaller cap anywhere in the @@ -3157,8 +3151,8 @@ sizing the points itself (the OptionalDouble rule). */ for the identical reason - rather than silently dropping to the new MCP default. 200 is well under both McpHelpers.MaxTop and MaxRowLimit (1000 each), so the value is never refused or reclamped by either validation layer. */ - ["get_object_locking"] = (c, pg, an) => DarlingMcpObjectStatsTools.GetObjectLocking(pg, Server(c), Rows(c, "limit", 200)), - ["get_table_index_sizes"] = (c, pg, an) => DarlingMcpObjectStatsTools.GetTableIndexSizes(pg, Server(c)), + ["get_object_locking"] = (c, pg, an) => DarlingMcpObjectStatsTools.GetObjectLocking(pg, Server(c), Rows(c, "limit", 200), cancellationToken: c.RequestAborted), + ["get_table_index_sizes"] = (c, pg, an) => DarlingMcpObjectStatsTools.GetTableIndexSizes(pg, Server(c), cancellationToken: c.RequestAborted), /* ── plan cache / scheduler ── */ ["get_cpu_scheduler_pressure"] = (c, pg, an) => DarlingMcpPlanCacheSchedulerTools.GetCpuSchedulerPressure(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs index d86ec5682..3ccea6f01 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs @@ -46,9 +46,10 @@ public static async Task GetServerConfigChanges( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168, - [Description(McpHelpers.AsOfDescription)] string? as_of = null) + [Description(McpHelpers.AsOfDescription)] string? as_of = null, + CancellationToken cancellationToken = default) { - var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name, cancellationToken); if (error != null) return error; var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); @@ -58,13 +59,13 @@ public static async Task GetServerConfigChanges( { var windowEndNaive = NaiveUtc(windowEnd); var windowStart = windowEndNaive.AddHours(-hours_back); - var snapshots = await DarlingConfigHistoryReader.GetServerConfigSnapshotsAsync(postgres, resolved.ServerId); + var snapshots = await DarlingConfigHistoryReader.GetServerConfigSnapshotsAsync(postgres, resolved.ServerId, cancellationToken); /* Unanchored, the tool still reads the full history and only lower-bounds — see UpperEdge, which keeps DateTime.MaxValue as the (no-op) upper edge so the shared both-edges diff reproduces the prior behaviour exactly. An as_of anchor is what closes the upper edge. */ var changes = ConfigChangeDiff.DiffServerConfigChanges(snapshots, windowStart, UpperEdge(as_of, windowEndNaive)); if (changes.Count == 0) - return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "server_config") + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "server_config", cancellationToken) ?? NoChanges(resolved.ServerName, hours_back, DistinctCaptures(snapshots.Select(s => s.CaptureTime))); var result = changes.Select(c => new @@ -87,7 +88,7 @@ prior behaviour exactly. An as_of anchor is what closes the upper edge. */ changes = result }, McpHelpers.JsonOptions); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { return McpHelpers.FormatError("get_server_config_changes", ex); } @@ -98,9 +99,10 @@ public static async Task GetDatabaseConfigChanges( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168, - [Description(McpHelpers.AsOfDescription)] string? as_of = null) + [Description(McpHelpers.AsOfDescription)] string? as_of = null, + CancellationToken cancellationToken = default) { - var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name, cancellationToken); if (error != null) return error; var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); @@ -110,10 +112,10 @@ public static async Task GetDatabaseConfigChanges( { var windowEndNaive = NaiveUtc(windowEnd); var windowStart = windowEndNaive.AddHours(-hours_back); - var snapshots = await DarlingConfigHistoryReader.GetDatabaseConfigSnapshotsAsync(postgres, resolved.ServerId); + var snapshots = await DarlingConfigHistoryReader.GetDatabaseConfigSnapshotsAsync(postgres, resolved.ServerId, cancellationToken); var changes = ConfigChangeDiff.DiffDatabaseConfigChanges(snapshots, windowStart, UpperEdge(as_of, windowEndNaive)); if (changes.Count == 0) - return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "database_config") + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "database_config", cancellationToken) ?? NoChanges(resolved.ServerName, hours_back, DistinctCaptures(snapshots.Select(s => s.CaptureTime))); var result = changes.Select(c => new @@ -133,7 +135,7 @@ public static async Task GetDatabaseConfigChanges( changes = result }, McpHelpers.JsonOptions); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { return McpHelpers.FormatError("get_database_config_changes", ex); } @@ -144,9 +146,10 @@ public static async Task GetTraceFlagChanges( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168, - [Description(McpHelpers.AsOfDescription)] string? as_of = null) + [Description(McpHelpers.AsOfDescription)] string? as_of = null, + CancellationToken cancellationToken = default) { - var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name, cancellationToken); if (error != null) return error; var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); @@ -156,10 +159,10 @@ public static async Task GetTraceFlagChanges( { var windowEndNaive = NaiveUtc(windowEnd); var windowStart = windowEndNaive.AddHours(-hours_back); - var snapshots = await DarlingConfigHistoryReader.GetTraceFlagSnapshotsAsync(postgres, resolved.ServerId); + var snapshots = await DarlingConfigHistoryReader.GetTraceFlagSnapshotsAsync(postgres, resolved.ServerId, cancellationToken); var changes = ConfigChangeDiff.DiffTraceFlagChanges(snapshots, windowStart, UpperEdge(as_of, windowEndNaive)); if (changes.Count == 0) - return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "trace_flags") + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "trace_flags", cancellationToken) ?? NoChanges(resolved.ServerName, hours_back, DistinctCaptures(snapshots.Select(s => s.CaptureTime))); var result = changes.Select(c => new @@ -182,7 +185,7 @@ public static async Task GetTraceFlagChanges( changes = result }, McpHelpers.JsonOptions); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { return McpHelpers.FormatError("get_trace_flag_changes", ex); } @@ -192,16 +195,17 @@ public static async Task GetTraceFlagChanges( public static async Task GetDatabaseScopedConfig( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Filter to a specific database. Omit for all databases.")] string? database_name = null) + [Description("Filter to a specific database. Omit for all databases.")] string? database_name = null, + CancellationToken cancellationToken = default) { - var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name, cancellationToken); if (error != null) return error; try { - var snapshot = await DarlingConfigHistoryReader.GetLatestDatabaseScopedConfigAsync(postgres, resolved.ServerId); + var snapshot = await DarlingConfigHistoryReader.GetLatestDatabaseScopedConfigAsync(postgres, resolved.ServerId, cancellationToken); if (snapshot.IsEmpty) - return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "database_scoped_config") + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "database_scoped_config", cancellationToken) ?? McpHelpers.Status( "unavailable", "No database-scoped configuration data available. The config collector may not have run yet."); @@ -232,7 +236,7 @@ public static async Task GetDatabaseScopedConfig( databases = grouped }, McpHelpers.JsonOptions); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { return McpHelpers.FormatError("get_database_scoped_config", ex); } diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs index 32f1a11a2..419f4e06b 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs @@ -11,6 +11,7 @@ using System.ComponentModel; using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using ModelContextProtocol.Server; using Npgsql; @@ -56,9 +57,10 @@ public sealed class DarlingMcpObjectStatsTools [McpServerTool(Name = "get_table_index_sizes"), Description("Gets the 100 largest tables with per-table size, growth (7d/30d/daily rate), and row counts from the latest daily snapshot. Indexes are rolled up per table. Use to find storage hot-spots and fast-growing tables for capacity planning. Growth is measured only over history the store actually holds: the history block says how many days of snapshots exist and whether the 7-day and 30-day baselines are reachable; growth_7d_mb / growth_30d_mb / growth_pct_30d are null (with the reason in growth_note) when their baseline does not exist, never re-labelled from a nearer one, and growth_over_available_history_* always spans exactly growth_window_days. A table absent from a baseline snapshot (created since) reports null growth for that window, not 0. tables_returned and truncated bound the page.")] public static async Task GetTableIndexSizes( NpgsqlDataSource postgres, - [Description("Server name or display name.")] string? server_name = null) + [Description("Server name or display name.")] string? server_name = null, + CancellationToken cancellationToken = default) { - var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name, cancellationToken); if (error != null) return error; try @@ -66,9 +68,9 @@ public static async Task GetTableIndexSizes( var now = DateTime.UtcNow; /* Over-fetch by one so truncation is observed, not inferred from a full page (#3541 A3's rule). */ var rows = await DarlingObjectStatsReader.GetObjectSizeGrowthAsync( - postgres, resolved.ServerId, now.AddDays(-7), now.AddDays(-30), TableSizesTop + 1); + postgres, resolved.ServerId, now.AddDays(-7), now.AddDays(-30), TableSizesTop + 1, cancellationToken); if (rows.Count == 0) - return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "index_object_stats") + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "index_object_stats", cancellationToken) ?? McpHelpers.Status("unavailable", "No object size data available. Index/object stats are collected daily."); var truncated = rows.Count > TableSizesTop; @@ -124,7 +126,7 @@ public static async Task GetTableIndexSizes( tables = result }, McpHelpers.JsonOptions); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { return McpHelpers.FormatError("get_table_index_sizes", ex); } @@ -163,9 +165,10 @@ public static async Task GetIndexUsage( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Limit to one database. Strongly recommended: without it, unused-first ordering can fill the whole result from one database.")] string? database_name = null, - [Description("Maximum rows to return. Default 75.")] int limit = IndexUsageTop) + [Description("Maximum rows to return. Default 75.")] int limit = IndexUsageTop, + CancellationToken cancellationToken = default) { - var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name, cancellationToken); if (error != null) return error; var validation = McpHelpers.ValidateTop(limit); @@ -175,7 +178,7 @@ public static async Task GetIndexUsage( { var database = string.IsNullOrWhiteSpace(database_name) ? null : database_name; - var rows = await DarlingObjectStatsReader.GetIndexUsageAsync(postgres, resolved.ServerId, limit, database); + var rows = await DarlingObjectStatsReader.GetIndexUsageAsync(postgres, resolved.ServerId, limit, database, cancellationToken); if (rows.Count == 0) { /* #2636: a database filter that matches nothing is a DIFFERENT answer from a server that @@ -184,7 +187,7 @@ capability check still runs first — a wrong-engine target has no index_object_ — and only then does the filter get blamed for its own empty result. */ if (database is not null) { - var anyOnServer = await DarlingObjectStatsReader.GetIndexUsageMatchCountAsync(postgres, resolved.ServerId); + var anyOnServer = await DarlingObjectStatsReader.GetIndexUsageMatchCountAsync(postgres, resolved.ServerId, cancellationToken: cancellationToken); if (anyOnServer > 0) { @@ -198,13 +201,13 @@ capability check still runs first — a wrong-engine target has no index_object_ } } - return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "index_object_stats") + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "index_object_stats", cancellationToken) ?? McpHelpers.Status("unavailable", "No index usage data available. Index/object stats are collected daily."); } /* Counted BEFORE the cap, by a second query. A count taken over the returned rows is a count of the page, which is the whole defect this answers. */ - var matching = await DarlingObjectStatsReader.GetIndexUsageMatchCountAsync(postgres, resolved.ServerId, database); + var matching = await DarlingObjectStatsReader.GetIndexUsageMatchCountAsync(postgres, resolved.ServerId, database, cancellationToken); var truncated = matching > rows.Count; var result = rows.Select(r => new @@ -242,7 +245,7 @@ capability check still runs first — a wrong-engine target has no index_object_ indexes = result }, McpHelpers.JsonOptions); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { return McpHelpers.FormatError("get_index_usage", ex); } @@ -252,9 +255,10 @@ capability check still runs first — a wrong-engine target has no index_object_ public static async Task GetObjectLocking( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Maximum rows to return. Default 75.")] int limit = ObjectLockingTop) + [Description("Maximum rows to return. Default 75.")] int limit = ObjectLockingTop, + CancellationToken cancellationToken = default) { - var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name, cancellationToken); if (error != null) return error; var validation = McpHelpers.ValidateTop(limit); @@ -265,11 +269,11 @@ public static async Task GetObjectLocking( /* #4198: limit + 1 as the fetch, the extra row as the OBSERVED truncation signal (#3653's dialect) -- McpHelpers.BoundPage trims the page back to `limit`, so objects_returned below is always a count of the page and never of the over-fetch. */ - var fetched = await DarlingObjectStatsReader.GetIndexLockingAsync(postgres, resolved.ServerId, limit + 1); + var fetched = await DarlingObjectStatsReader.GetIndexLockingAsync(postgres, resolved.ServerId, limit + 1, cancellationToken); var (rows, truncated) = McpHelpers.BoundPage(fetched, limit); if (rows.Count == 0) - return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "index_object_stats") + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "index_object_stats", cancellationToken) ?? McpHelpers.Status("unavailable", "No locking/contention data recorded. Index/object stats are collected daily."); var result = rows.Select(r => new @@ -315,7 +319,7 @@ agent reads a 23-hour-old contention picture as "now". */ objects = result }, McpHelpers.JsonOptions); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { return McpHelpers.FormatError("get_object_locking", ex); } @@ -324,16 +328,17 @@ agent reads a 23-hour-old contention picture as "now". */ [McpServerTool(Name = "get_database_sizes"), Description("Gets database file sizes, space usage, and volume free space. Shows each database file with total size, used space, auto-growth settings, and the underlying volume's capacity. Use for capacity planning and identifying space pressure. LATEST IS A TIME: this reads the newest size snapshot, not a window, and captured_at is the instant it was collected - a volume's free space here is what it was AT that stamp, and a file that grew since is not reflected until the next collection.")] public static async Task GetDatabaseSizes( NpgsqlDataSource postgres, - [Description("Server name or display name.")] string? server_name = null) + [Description("Server name or display name.")] string? server_name = null, + CancellationToken cancellationToken = default) { - var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name, cancellationToken); if (error != null) return error; try { - var rows = await DarlingObjectStatsReader.GetLatestDatabaseSizesAsync(postgres, resolved.ServerId); + var rows = await DarlingObjectStatsReader.GetLatestDatabaseSizesAsync(postgres, resolved.ServerId, cancellationToken); if (rows.Count == 0) - return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "database_size_stats") + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "database_size_stats", cancellationToken) ?? McpHelpers.Status("unavailable", "No database size data available. The size collector may not have run yet."); return JsonSerializer.Serialize(new @@ -365,7 +370,7 @@ public static async Task GetDatabaseSizes( }) }, McpHelpers.JsonOptions); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { return McpHelpers.FormatError("get_database_sizes", ex); }