diff --git a/Darling/Darling.Tests/DarlingMcpBlockingBudgetLiveTests.cs b/Darling/Darling.Tests/DarlingMcpBlockingBudgetLiveTests.cs
new file mode 100644
index 000000000..c3b9f33ef
--- /dev/null
+++ b/Darling/Darling.Tests/DarlingMcpBlockingBudgetLiveTests.cs
@@ -0,0 +1,163 @@
+/*
+ * 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.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Service.Mcp;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4198: get_blocking's own response-budget pin. Every row on this tool carries ~37 fields (isolation
+/// levels, client app/host/login for both sides, six last-tran/last-batch stamps, a dedup_key) before either
+/// SQL text column is counted, so the default page's own width times the default row LIMIT is most of the
+/// bytes here — not one wide field the way get_deadlock_detail's graph is. Plants 30 rows (the default
+/// limit) with realistic-width blocked/blocking SQL text and asserts the default call stays under
+/// , that full_text opts back into the whole text on both
+/// columns, and that a dedup_key call (naming one incident) returns the whole text even without
+/// full_text. New file (not the shared seeding in )
+/// because #4198 ran a dozen lanes against this store tonight.
+///
+[Collection("live-postgres")]
+public sealed class DarlingMcpBlockingBudgetLiveTests
+{
+ private const string ServerName = "darling-mcp-blocking-budget-e2e";
+ private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName);
+ private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ private readonly ITestOutputHelper _output;
+
+ public DarlingMcpBlockingBudgetLiveTests(ITestOutputHelper output) => _output = output;
+
+ [Fact]
+ public async Task GetBlocking_Default_StaysUnderResponseBudget_WithThirtyRealisticRows()
+ {
+ var cs = ConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live get_blocking budget test.");
+
+ var ct = TestContext.Current.CancellationToken;
+ using var connection = new NpgsqlConnection(cs);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DeleteRowsAsync(connection, ct);
+ await using var postgres = NpgsqlDataSource.Create(cs!);
+
+ var bodySucceeded = false;
+ try
+ {
+ await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct);
+ var baseTime = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow).AddMinutes(-60);
+ var blockedSql = BuildQueryText("UPDATE dbo.Posts SET Score = Score + 1, LastActivityDate = GETUTCDATE()", approxLength: 650);
+ var blockingSql = BuildQueryText("SELECT p.Id, p.Title, p.Body, u.DisplayName FROM dbo.Posts p JOIN dbo.Users u ON u.Id = p.OwnerUserId", approxLength: 820);
+
+ for (var i = 0; i < 30; i++)
+ {
+ var t = baseTime.AddMinutes(i);
+ await DarlingMcpTestData.ExecAsync(connection, ct,
+ @"INSERT INTO blocked_process_reports
+ (blocked_report_id, collection_time, server_id, server_name, event_time, database_name,
+ blocked_spid, blocked_ecid, blocking_spid, blocking_ecid, wait_time_ms, wait_resource, lock_mode,
+ blocked_status, blocked_isolation_level, blocked_log_used, blocked_transaction_count,
+ blocked_client_app, blocked_host_name, blocked_login_name, blocked_sql_text,
+ blocking_status, blocking_isolation_level, blocking_client_app, blocking_host_name, blocking_login_name,
+ blocking_sql_text, blocked_transaction_name, blocking_transaction_name,
+ blocked_last_tran_started, blocking_last_tran_started, blocked_last_batch_started, blocking_last_batch_started,
+ blocked_last_batch_completed, blocking_last_batch_completed, blocked_priority, blocking_priority,
+ blocked_process_report_xml, contentious_object)
+VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39)",
+ CollectionIdGenerator.Next(), t, ServerId, ServerName, t, "StackOverflow",
+ 100 + i, 0, 50 + (i % 5), 0, 5000L + i * 137, "KEY: 6:72057594057849856 (3a1c2b4e5f6a)", "X",
+ "suspended", "READ COMMITTED", 4096L, 1,
+ ".Net SqlClient Data Provider", "APPSERVER01", "CONTOSO\\svc_app", blockedSql + $" /* row {i} */",
+ "running", "READ COMMITTED", "Microsoft SQL Server Management Studio", "DBASERVER02", "CONTOSO\\dba_erik",
+ blockingSql + $" /* row {i} */", "user_transaction", "user_transaction",
+ t, t, t, t,
+ t, t, 0, 0,
+ "", "dbo.Posts");
+ }
+
+ var defaultJson = await DarlingMcpBlockingTools.GetBlocking(postgres, ServerName);
+ DarlingMcpTestData.AssertEnvelope(defaultJson, ServerName, "events");
+ JsonAssert.Contains($"\"events_returned\": {DarlingMcpBlockingTools.DefaultLimit}", defaultJson);
+ JsonAssert.Contains("\"truncated\": true", defaultJson);
+
+ /* #4198: BEFORE this lane's fix, 30 planted rows at this realistic-but-modest text width (well
+ under the old 2000-char cap, so neither text column was even truncated) measured 89,096 bytes
+ — 2.7x McpResponseBudget.DefaultBytes — because the row's other ~37 fields (isolation levels,
+ client app/host/login for both sides, six last-tran/last-batch stamps) are most of the weight
+ at the default 30-row page, not the two text columns alone. */
+ JsonAssert.Contains("\"blocked_sql_text_truncated\": true", defaultJson);
+ JsonAssert.Contains("\"blocking_sql_text_truncated\": true", defaultJson);
+
+ var defaultBytes = Encoding.UTF8.GetByteCount(defaultJson);
+ _output.WriteLine($"get_blocking default call: {defaultBytes:N0} bytes (budget {McpResponseBudget.DefaultBytes:N0}), 30 planted rows, blocked/blocking SQL text {blockedSql.Length:N0}/{blockingSql.Length:N0} chars.");
+ Assert.True(defaultBytes < McpResponseBudget.DefaultBytes,
+ $"get_blocking's default call is {defaultBytes:N0} bytes, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget.");
+
+ using var defaultParsed = JsonDocument.Parse(defaultJson);
+ var defaultFirstBlocked = defaultParsed.RootElement.GetProperty("events")[0].GetProperty("blocked_sql_text").GetString();
+ Assert.NotNull(defaultFirstBlocked);
+ Assert.True(defaultFirstBlocked!.Length < blockedSql.Length,
+ "the default call's blocked_sql_text should be a preview shorter than the planted text.");
+
+ /* full_text opts back into the whole text on both columns. */
+ var fullJson = await DarlingMcpBlockingTools.GetBlocking(postgres, ServerName, limit: 30, full_text: true);
+ Assert.DoesNotContain("_truncated\": true", fullJson, StringComparison.Ordinal);
+ using var fullParsed = JsonDocument.Parse(fullJson);
+ var fullFirstBlocked = fullParsed.RootElement.GetProperty("events")[0].GetProperty("blocked_sql_text").GetString();
+ /* Newest first: events[0] is row 29 (latest event_time), not row 0 — just check the text is
+ whole (starts with the planted clause, un-truncated) rather than hardcode which row sorts first. */
+ Assert.NotNull(fullFirstBlocked);
+ Assert.StartsWith(blockedSql, fullFirstBlocked!, StringComparison.Ordinal);
+ Assert.True(fullFirstBlocked!.Length > blockedSql.Length, "the full_text call should include the ' /* row N */' suffix past the planted clause.");
+
+ /* Naming one incident (dedup_key) returns the whole text even without full_text. */
+ var firstKey = defaultParsed.RootElement.GetProperty("events")[0].GetProperty("dedup_key").GetString();
+ Assert.False(string.IsNullOrEmpty(firstKey));
+
+ var byKeyJson = await DarlingMcpBlockingTools.GetBlocking(postgres, ServerName, dedup_key: firstKey, full_text: false);
+ Assert.DoesNotContain("_truncated\": true", byKeyJson, StringComparison.Ordinal);
+
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =>
+ await DeleteRowsAsync(cleanup, cleanupCt));
+ }
+ }
+
+ /// Builds a T-SQL string near characters by repeating a
+ /// realistic clause, ASCII only so its .NET UTF-16 char length and its UTF-8 byte size stay close.
+ private static string BuildQueryText(string clause, int approxLength)
+ {
+ var sb = new StringBuilder(clause);
+ var i = 0;
+ while (sb.Length < approxLength)
+ {
+ sb.Append($" AND p.Id NOT IN (SELECT TOP (1) Id FROM dbo.Posts WHERE ParentId = {i})");
+ i++;
+ }
+
+ return sb.ToString();
+ }
+
+ private static async Task DeleteRowsAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct)
+ {
+ using var cleanup = new NpgsqlCommand(
+ $"DELETE FROM blocked_process_reports WHERE server_id = {ServerId}; DELETE FROM servers WHERE server_id = {ServerId};",
+ connection);
+ await cleanup.ExecuteNonQueryAsync(ct);
+ }
+}
diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs
index 787b06244..da34cc89e 100644
--- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs
+++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs
@@ -1534,6 +1534,10 @@ public static readonly (string Key, string[] Files, string WhatWasCut)[] FieldPr
"#4198: query_text is previewed at read time by two tools: get_active_queries previews at 500 chars (full_text gets the whole text; a synthetic 50-row page measured 81,489 bytes), get_plan_corrections previews at 150 chars (full_text gets the whole text; the full text IS in the store, not collector-capped)"),
("error_message_truncated", ["DarlingMcpDataTools.cs", "McpHealthTools.cs"],
"#4198: get_collection_log's own wide field — error_message is a 500-character preview by default (a seeded store measured 90,514 bytes for 200 rows at the old 200-row default), full_text opts back into the whole (up to 4000-character, DarlingObservability.LogCollectionAsync's own write-time ceiling) field"),
+ ("blocked_sql_text_truncated", ["DarlingMcpBlockingTools.cs", "McpBlockingTools.cs"],
+ "#4198: get_blocking/get_blocked_process_reports' blocked_sql_text previewed to SqlTextPreviewLength (150 chars) — the default row LIMIT also halved (30 -> 15), because the row's other ~37 fields, not this column alone, were most of the default page's weight; full_text or a dedup_key call (Darling only) gets the whole text"),
+ ("blocking_sql_text_truncated", ["DarlingMcpBlockingTools.cs", "McpBlockingTools.cs"],
+ "#4198: get_blocking/get_blocked_process_reports' blocking_sql_text, previewed the same way as blocked_sql_text_truncated"),
("top_query_text_truncated", ["DarlingMcpQueryHeatmapTools.cs", "McpQueryTools.cs"],
"get_query_heatmap's (#4198) per-cell top-query preview width (DefaultPreviewLength on both SKUs) — the full statement is already in the store; full_text opts back into it rather than re-paging, so this is not the page dialect's truncated and nothing was lost the way a source-side cut loses it"),
];
diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt
index 91158debe..c0c9ce2ed 100644
--- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt
+++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt
@@ -5,9 +5,10 @@ param get_blocked_process_xml.hours_back 29
param get_blocked_process_xml.limit 113
param get_blocked_process_xml.server_name 28
-tool get_blocking 619
+tool get_blocking 576
param get_blocking.as_of 167
param get_blocking.dedup_key 191
+param get_blocking.full_text 172
param get_blocking.hours_back 29
param get_blocking.limit 133
param get_blocking.server_name 28
diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs
index 520c47a7e..d0baff092 100644
--- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs
+++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs
@@ -110,6 +110,11 @@ so neither counts here. */
/* #4198 (lane TB): +364 bytes for get_deadlock_detail's default-preview note in its served description
and its new full_graph opt-in parameter (deadlock_graph_xml, the wide field, is now a 2000-char
preview by default). */
+ /* #4198 (lane TI): +391 bytes for get_blocking's default-preview note in its served description and its
+ new full_text opt-in parameter (blocked_sql_text/blocking_sql_text are now a 150-char preview by
+ default). The default row limit also dropped 30 -> 15 -- 30 rows of even sub-2000-char (never
+ truncated under the OLD cap) text measured 89,096 bytes, 2.7x the budget, because the row's other ~37
+ fields were most of the weight -- but that is not a served description, so it does not count here. */
/* #4198: get_collection_log's per-server form gained full_text (its error_message preview opt-in,
76 bytes) and limit's own description banked 1 byte describing the new lower default. +127 net. */
/* #4198 (per-tool lane, get_object_locking): +79 bytes for the new limit parameter (default lowered from
@@ -120,7 +125,9 @@ so neither counts here. */
of active_queries (#4261) + object_locking (#4258) changes on top of dev. */
/* #4198 (collection_log, merge): re-measured after merging origin/dev (dev now includes #4261+#4258);
combined total with collection_log (#4265) changes on top. */
- private const int TotalCeilingBytes = 172_760;
+ /* #4198 (blocking, merge): re-measured after merging origin/dev (dev now includes #4261+#4258+#4265);
+ combined total with blocking (#4267) changes on top. */
+ private const int TotalCeilingBytes = 172_942;
private const int ConvertedHeadCap = 1_000;
private const int ConvertedParameterCap = 200;
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
index b25752ace..58d3d78ef 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
@@ -2631,7 +2631,11 @@ logger is the tool's logger seat — the web host's SERVICE logger when MapAll b
/* ── blocking / deadlocks ── */
["get_blocked_process_xml"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlockedProcessXml(pg, Server(c), Hours(c, 24), Rows(c, "limit", 5), as_of: AsOf(c)),
- ["get_blocking"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlocking(pg, Server(c), Hours(c, 24), Rows(c, "limit", 30), as_of: AsOf(c)),
+ /* #4198: the internal overload, not the MCP tool wrapper — pins the OLD row limit (30) and the
+ OLD 2000-char text cap (WebSqlTextPreviewLength) explicitly, so this page does not change even
+ though the tool's own MCP defaults (limit 15, 150-char preview) did. Same shape #3897's trend
+ tools use to pass TrendBudget.Chart here instead of their own MCP point budget. */
+ ["get_blocking"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlocking(pg, Server(c), Hours(c, 24), Rows(c, "limit", 30), null, false, AsOf(c), DarlingMcpBlockingTools.WebSqlTextPreviewLength),
["get_blocking_trend"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlockingTrend(pg, Server(c), Hours(c, 24), as_of: AsOf(c)),
/* #4254: full_graph defaults false on the MCP signature (a preview keeps a busy production
store's tools/list-driven call under the shared response budget), but the web viewer has
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs
index cb6c32eb7..b897d00a6 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs
@@ -44,14 +44,49 @@ namespace PerformanceMonitor.Darling.Service.Mcp;
[McpServerToolType]
public sealed class DarlingMcpBlockingTools
{
- [McpServerTool(Name = "get_blocking"), Description("Blocked process report XE + DMV fallback events, newest first, window ends at as_of. not_collected wins if the engine can't run blocked_process_report; else empty means none in the window, or none collected in it. limit caps ROWS, not hours_back: truncated true means raise limit or narrow the window, not widen hours_back. dedup_key scans the whole window before limit, up to a stated ceiling (rows_examined/scan_truncated); a no-match answer is still empty. wait_time_ms is milliseconds. Timestamps are UTC; last_tran/last_batch stamps are de-skewed for direct comparison to event_time.<>Gets blocking events captured by the blocked process report extended event (plus the always-on DMV blocking-snapshot fallback), NEWEST FIRST. Shows the blocked and blocking sessions, wait types, wait times, and query text for both. Use this first for a quick overview, then use get_blocked_process_xml for deep analysis of prolonged blocking. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: hours_back is the window you ASKED for, events_returned is how many rows you GOT, truncated says the window held more than limit, and oldest_returned_event_time / newest_returned_event_time bound the page you are looking at. Because the page is a contiguous newest-first slice, oldest_returned_event_time IS how far back this read reached — on a server blocking steadily, a 24-hour request at the default limit is answered by the newest few minutes, and nothing in the rows themselves says so. When truncated is true, raise limit or narrow hours_back (or anchor as_of) before drawing a conclusion about the window; widening hours_back cannot help, because the cap is on rows, not time. With dedup_key the read scans the window for the fingerprint BEFORE limit applies (so a matching incident is never lost to the cap), up to a stated scan ceiling: rows_examined is how many rows were fingerprinted and scan_truncated says whether the window held more than the scan could reach. Every timestamp here is UTC: event_time already was, and the six blocked_/blocking_ last_tran/last_batch stamps are de-skewed from the monitored server's local clock by this read, so comparing them against event_time to see whether a transaction predates the block is direct. dedup_key: Optional alert fingerprint (the alert's Dedup Key). When supplied, returns only the incident with that key — paste it straight from an alert or ticket instead of scanning the window. The key is scoped to the server's display name and the incident's involved objects. The fingerprint scan runs over the window BEFORE limit, up to the scan ceiling the payload reports as rows_examined / scan_truncated.")]
- public static async Task GetBlocking(
+ ///
+ /// #4198: get_blocking's default page is wide before either text column is counted — ~37 fields per row
+ /// (isolation levels, client app/host/login for both sides, six last-tran/last-batch stamps, a
+ /// dedup_key) — so 30 rows of even modest (sub-2000-char, i.e. never hitting the old truncation) SQL
+ /// text still measured 89,096 bytes, 2.7x the shared budget. Both levers move: the default row limit
+ /// halves (30 -> 15, the same proportion #4198's get_plan_corrections used), and blocked_sql_text /
+ /// blocking_sql_text preview to (150, get_plan_corrections'
+ /// QueryTextPreviewLength value) rather than the old 2000, with full_text the opt-in back to the
+ /// whole text — the same shape get_store_query_stats already uses. A dedup_key call is
+ /// exempt (it already named one incident) and always gets the whole text, same as get_deadlock_detail.
+ ///
+ public const int DefaultLimit = 15;
+
+ /// See 's doc comment.
+ private const int SqlTextPreviewLength = 150;
+
+ ///
+ /// The web viewer's /api/read mirror passes this instead (#4198): the OLD 2000-char cap, so the
+ /// page it renders does not change. It is not full_text (unbounded) because the web never showed
+ /// more than 2000 chars of either column before this lane, and passing true would grow the web's own
+ /// payload past what it has always been.
+ ///
+ public const int WebSqlTextPreviewLength = 2000;
+
+ [McpServerTool(Name = "get_blocking"), Description("Blocked process report XE + DMV fallback events, newest first, window ends at as_of. not_collected wins if the engine can't run blocked_process_report; empty means none in the window, or none collected in it. limit caps ROWS, not hours_back: raise limit or narrow the window, not widen hours_back. dedup_key scans the whole window before limit, up to a stated ceiling (rows_examined/scan_truncated); a no-match answer is still empty. wait_time_ms is milliseconds; last_tran/last_batch stamps are de-skewed to compare directly against event_time.<>Gets blocking events captured by the blocked process report extended event (plus the always-on DMV blocking-snapshot fallback), NEWEST FIRST. Shows the blocked and blocking sessions, wait types, wait times, and query text for both. Use this first for a quick overview, then use get_blocked_process_xml for deep analysis of prolonged blocking. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: hours_back is the window you ASKED for, events_returned is how many rows you GOT, truncated says the window held more than limit, and oldest_returned_event_time / newest_returned_event_time bound the page you are looking at. Because the page is a contiguous newest-first slice, oldest_returned_event_time IS how far back this read reached — on a server blocking steadily, a 24-hour request at the default limit is answered by the newest few minutes, and nothing in the rows themselves says so. When truncated is true, raise limit or narrow hours_back (or anchor as_of) before drawing a conclusion about the window; widening hours_back cannot help, because the cap is on rows, not time. With dedup_key the read scans the window for the fingerprint BEFORE limit applies (so a matching incident is never lost to the cap), up to a stated scan ceiling: rows_examined is how many rows were fingerprinted and scan_truncated says whether the window held more than the scan could reach. Every timestamp here is UTC: event_time already was, and the six blocked_/blocking_ last_tran/last_batch stamps are de-skewed from the monitored server's local clock by this read, so comparing them against event_time to see whether a transaction predates the block is direct. blocked_sql_text/blocking_sql_text are a preview by default (*_truncated marks the cut rows) — pass full_text for the whole text on every row; a dedup_key call always gets the whole text regardless of full_text. dedup_key: Optional alert fingerprint (the alert's Dedup Key). When supplied, returns only the incident with that key — paste it straight from an alert or ticket instead of scanning the window. The key is scoped to the server's display name and the incident's involved objects. The fingerprint scan runs over the window BEFORE limit, up to the scan ceiling the payload reports as rows_examined / scan_truncated.")]
+ public static Task GetBlocking(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
[Description("Hours of history. Default 24.")] int hours_back = 24,
- [Description("Maximum rows to return, newest first. Default 30. This is what bounds the page — read truncated to know whether the window held more.")] int limit = 30,
+ [Description("Maximum rows to return, newest first. Default 15. This is what bounds the page — read truncated to know whether the window held more.")] int limit = DefaultLimit,
[Description("Optional alert fingerprint (the alert's Dedup Key). The key is scoped to the server's display name and the incident's involved objects. The fingerprint scan runs over the window BEFORE limit.")] string? dedup_key = null,
- [Description(McpHelpers.AsOfDescription)] string? as_of = null)
+ [Description("Return each row's full blocked_sql_text/blocking_sql_text instead of a 150-character preview. Default false. A dedup_key call ignores this and always returns the full text.")] bool full_text = false,
+ [Description(McpHelpers.AsOfDescription)] string? as_of = null) =>
+ GetBlocking(postgres, server_name, hours_back, limit, dedup_key, full_text, as_of, SqlTextPreviewLength);
+
+ ///
+ /// get_blocking under an explicit (#4198): the MCP tool passes
+ /// its own default (or a dedup_key/full_text call passes through to the whole text below), the web
+ /// viewer's /api/read mirror passes so its page does not
+ /// change.
+ ///
+ internal static async Task GetBlocking(
+ NpgsqlDataSource postgres, string? server_name, int hours_back, int limit, string? dedup_key, bool full_text, string? as_of, int sqlTextPreviewLength)
{
var (resolved, error) = await DarlingServerResolver.ResolveWithFingerprintNameAsync(postgres, server_name);
if (error != null) return error;
@@ -123,6 +158,10 @@ the newest `limit` rows that happen to include it. Without a key the scan IS the
var truncated = rows.Count > limit;
var page = rows.Take(limit).ToList();
+ /* #4198: filtering (a dedup_key) already narrowed the page to one named incident, so that call
+ is exempt from the preview cut — see SqlTextPreviewLength's doc comment. */
+ var showFullText = full_text || filtering;
+
var result = page.Select((r, i) => new
{
event_time = r.EventTime?.ToString("o"),
@@ -143,13 +182,15 @@ the newest `limit` rows that happen to include it. Without a key the scan IS the
blocked_client_app = r.BlockedClientApp,
blocked_host_name = r.BlockedHostName,
blocked_login_name = r.BlockedLoginName,
- blocked_sql_text = McpHelpers.Truncate(r.BlockedSqlText, 2000),
+ blocked_sql_text = showFullText ? r.BlockedSqlText : McpHelpers.Truncate(r.BlockedSqlText, sqlTextPreviewLength),
+ blocked_sql_text_truncated = !showFullText && r.BlockedSqlText != null && r.BlockedSqlText.Length > sqlTextPreviewLength,
blocking_status = r.BlockingStatus,
blocking_isolation_level = r.BlockingIsolationLevel,
blocking_client_app = r.BlockingClientApp,
blocking_host_name = r.BlockingHostName,
blocking_login_name = r.BlockingLoginName,
- blocking_sql_text = McpHelpers.Truncate(r.BlockingSqlText, 2000),
+ blocking_sql_text = showFullText ? r.BlockingSqlText : McpHelpers.Truncate(r.BlockingSqlText, sqlTextPreviewLength),
+ blocking_sql_text_truncated = !showFullText && r.BlockingSqlText != null && r.BlockingSqlText.Length > sqlTextPreviewLength,
blocked_transaction_name = r.BlockedTransactionName,
blocking_transaction_name = r.BlockingTransactionName,
blocked_last_tran_started = r.BlockedLastTranStartedUtc?.ToString("o"),
diff --git a/Lite.Tests/McpPageContractTests.cs b/Lite.Tests/McpPageContractTests.cs
index 0a24fc823..a0486ae26 100644
--- a/Lite.Tests/McpPageContractTests.cs
+++ b/Lite.Tests/McpPageContractTests.cs
@@ -238,6 +238,93 @@ public async Task GetBlockedProcessXml_LimitCountsReportsWithXml_NotRows()
AssertPage(cut, "reports", "reports_returned", returned: 1, truncated: true);
}
+ ///
+ /// #4198: blocked_sql_text/blocking_sql_text are the wide fields here — Darling's twin
+ /// (DarlingMcpBlockingBudgetLiveTests) measured 89,096 bytes at the old defaults on a real
+ /// production store. Plants 30 reports with both text fields near 700-870 characters (a realistic
+ /// blocked/blocking statement width, every other field populated) and asserts the default call
+ /// previews them under , and that full_text: true
+ /// opts back into the whole text.
+ ///
+ [Fact]
+ public async Task GetBlockedProcessReports_Default_StaysUnderResponseBudget_WithThirtyWideReports()
+ {
+ var now = WholeSecondsNow();
+ var blockedTexts = new string[30];
+ var blockingTexts = new string[30];
+ for (var i = 0; i < 30; i++)
+ {
+ blockedTexts[i] = BuildWideSqlText(700 + i * 6, "Orders");
+ blockingTexts[i] = BuildWideSqlText(700 + i * 5, "Posts");
+ await ExecAsync(@"
+INSERT INTO blocked_process_reports
+ (blocked_report_id, collection_time, server_id, server_name, event_time, database_name,
+ blocked_spid, blocked_ecid, blocking_spid, blocking_ecid, wait_time_ms, wait_resource, lock_mode,
+ blocked_status, blocked_isolation_level, blocked_log_used, blocked_transaction_count,
+ blocked_client_app, blocked_host_name, blocked_login_name, blocked_sql_text,
+ blocking_status, blocking_isolation_level, blocking_client_app, blocking_host_name, blocking_login_name, blocking_sql_text,
+ blocked_transaction_name, blocking_transaction_name,
+ blocked_last_tran_started, blocking_last_tran_started, blocked_last_batch_started, blocking_last_batch_started,
+ blocked_last_batch_completed, blocking_last_batch_completed, blocked_priority, blocking_priority,
+ contentious_object)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38)",
+ _nextId--, Naive(now.AddMinutes(-i)), _serverId, ServerName, Naive(now.AddMinutes(-i)), "Db",
+ 50 + i, 0, 90 + i, 0, 8000L + i, "KEY: 6:72057594057000000 (deadbeefcafe)", "X",
+ "SUSPENDED", "READ COMMITTED", 256L, 1,
+ "MyApp.exe", "APPSRV01", "svc_app", blockedTexts[i],
+ "RUNNING", "READ COMMITTED", "MyApp.exe", "APPSRV02", "svc_app2", blockingTexts[i],
+ "user_transaction", "user_transaction",
+ Naive(now.AddMinutes(-i - 5)), Naive(now.AddMinutes(-i - 5)), Naive(now.AddMinutes(-i - 1)), Naive(now.AddMinutes(-i - 1)),
+ Naive(now.AddMinutes(-i)), Naive(now.AddMinutes(-i)), 7, 3,
+ "dbo.Orders");
+ }
+
+ var defaultJson = await McpBlockingTools.GetBlockedProcessReports(_dataService, _serverManager, ServerName);
+ var root = Parse(defaultJson);
+ AssertPage(root, "reports", "reports_returned", returned: 15, truncated: true);
+ Assert.All(root.GetProperty("reports").EnumerateArray(), r =>
+ {
+ Assert.True(r.GetProperty("blocked_sql_text_truncated").GetBoolean());
+ Assert.True(r.GetProperty("blocking_sql_text_truncated").GetBoolean());
+ Assert.True(r.GetProperty("blocked_sql_text").GetString()!.Length < 700);
+ Assert.True(r.GetProperty("blocking_sql_text").GetString()!.Length < 700);
+ });
+
+ var defaultBytes = System.Text.Encoding.UTF8.GetByteCount(defaultJson);
+ Assert.True(defaultBytes < McpResponseBudget.DefaultBytes,
+ $"get_blocked_process_reports's default call is {defaultBytes:N0} bytes over 30 planted ~700-870-char reports, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget.");
+
+ var fullJson = await McpBlockingTools.GetBlockedProcessReports(_dataService, _serverManager, ServerName, 24, 30, full_text: true);
+ var fullRoot = Parse(fullJson);
+ AssertPage(fullRoot, "reports", "reports_returned", returned: 30, truncated: false);
+ var fullReports = fullRoot.GetProperty("reports").EnumerateArray().ToList();
+ for (var i = 0; i < 30; i++)
+ {
+ Assert.False(fullReports[i].GetProperty("blocked_sql_text_truncated").GetBoolean());
+ Assert.False(fullReports[i].GetProperty("blocking_sql_text_truncated").GetBoolean());
+ Assert.Equal(blockedTexts[i], fullReports[i].GetProperty("blocked_sql_text").GetString());
+ Assert.Equal(blockingTexts[i], fullReports[i].GetProperty("blocking_sql_text").GetString());
+ }
+ }
+
+ /// Builds a SQL statement string near characters, ASCII only so its
+ /// length and its UTF-8 byte count stay equal (production blocked/blocking statement text is almost
+ /// entirely ASCII: object names, predicates, literals).
+ private static string BuildWideSqlText(int approxLength, string table)
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.Append($"UPDATE dbo.{table} SET Status = 'Processing' WHERE ");
+ var i = 0;
+ while (sb.Length < approxLength)
+ {
+ sb.Append($"{table}Id = {i} OR ");
+ i++;
+ }
+
+ sb.Length = approxLength;
+ return sb.ToString();
+ }
+
///
/// The hidden filter, stated and measured. Default read: dismissed rows are gone, the payload says so
/// and says HOW MANY; include_dismissed brings them back with each row labelled. Truncation is
diff --git a/Lite.Tests/McpToolsListBudget/McpBlockingTools.txt b/Lite.Tests/McpToolsListBudget/McpBlockingTools.txt
index a2e3d3b09..8dadad5e4 100644
--- a/Lite.Tests/McpToolsListBudget/McpBlockingTools.txt
+++ b/Lite.Tests/McpToolsListBudget/McpBlockingTools.txt
@@ -1,6 +1,7 @@
# McpBlockingTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks.
-tool get_blocked_process_reports 483
+tool get_blocked_process_reports 600
param get_blocked_process_reports.as_of 167
+param get_blocked_process_reports.full_text 108
param get_blocked_process_reports.hours_back 29
param get_blocked_process_reports.limit 133
param get_blocked_process_reports.server_name 28
diff --git a/Lite.Tests/McpToolsListBudgetTests.cs b/Lite.Tests/McpToolsListBudgetTests.cs
index d067a73c4..8cb1f165e 100644
--- a/Lite.Tests/McpToolsListBudgetTests.cs
+++ b/Lite.Tests/McpToolsListBudgetTests.cs
@@ -101,10 +101,18 @@ change exactly. */
and its new full_graph opt-in parameter (deadlock_graph_xml, the wide field, is now a 2000-char
preview by default). Darling's twin grew by a different amount (+364): Darling's description also
covers the dedup_key exemption, which Lite's get_deadlock_detail has no dedup_key parameter to need. */
+ /* #4198 (lane TI): +271 bytes for get_blocked_process_reports' default-preview note in its served
+ description and its new full_text opt-in parameter (blocked_sql_text/blocking_sql_text are now a
+ 150-char preview by default; the default row limit also dropped 30 -> 15, since the row's other ~37
+ fields, not the two text columns, were most of the default page's weight). Darling's twin grew by a
+ different amount (+391): Darling's description also covers the dedup_key exemption, which Lite's
+ get_blocked_process_reports has no dedup_key parameter to need. */
/* #4198: get_collection_log's per-server form gained full_text (76 bytes), matching Darling's twin;
limit's own description banked 1 byte. +116 net (Lite's server_name description is shorter than
Darling's, since it has no fleet-maintenance sentinel to warn about). */
- private const int TotalCeilingBytes = 90_800;
+ /* #4198 (blocking, merge): re-measured after merging origin/dev (dev now includes #4261+#4258+#4265);
+ combined total with blocking (#4267) changes on top. */
+ private const int TotalCeilingBytes = 91_083;
private const int ConvertedHeadCap = 1_000;
private const int ConvertedParameterCap = 200;
diff --git a/Lite/Mcp/McpBlockingTools.cs b/Lite/Mcp/McpBlockingTools.cs
index 21fde1e75..a7cd0d5b5 100644
--- a/Lite/Mcp/McpBlockingTools.cs
+++ b/Lite/Mcp/McpBlockingTools.cs
@@ -150,13 +150,20 @@ older graphs sat behind the cap. */
}
}
- [McpServerTool(Name = "get_blocked_process_reports"), Description("Blocked process report XE + DMV fallback events, newest first, window ends at as_of. not_collected wins if the engine can't run blocked_process_report; else empty means none in the window, or none collected in it. limit caps ROWS, not hours_back: truncated true means raise limit or narrow the window, not widen hours_back. wait_time_ms is milliseconds. Timestamps are UTC; last_tran/last_batch stamps are de-skewed for direct comparison to event_time. <> Gets detailed blocked process reports from extended events (parsed via sp_HumanEventsBlockViewer) plus the always-on DMV blocking-snapshot fallback, NEWEST FIRST. Provides detailed blocked/blocking session info: isolation levels, transaction names, full query text for both sessions. Use for deep analysis of prolonged blocking. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: hours_back is the window you ASKED for, reports_returned is how many rows you GOT, truncated says the window held more than limit, and oldest_returned_event_time / newest_returned_event_time bound the page you are looking at. Because the page is a contiguous newest-first slice, oldest_returned_event_time IS how far back this read reached — on a server blocking steadily, a 24-hour request at the default limit is answered by the newest few minutes, and nothing in the rows themselves says so. When truncated is true, raise limit or narrow hours_back (or anchor as_of) before drawing a conclusion about the window; widening hours_back cannot help, because the cap is on rows, not time. Every timestamp here is UTC: event_time already was, and the six blocked_/blocking_ last_tran/last_batch stamps are de-skewed from the monitored server's local clock by this read, so comparing them against event_time to see whether a transaction predates the block is direct.")]
+ /// See DarlingMcpBlockingTools.DefaultLimit's doc comment (#4198): the default row limit
+ /// halves (30 -> 15) and blocked_sql_text/blocking_sql_text preview to
+ /// (150) rather than the old 2000, with full_text the opt-in
+ /// back to the whole text. Lite has no web viewer mirror to hold the old cap for, unlike Darling's twin.
+ private const int SqlTextPreviewLength = 150;
+
+ [McpServerTool(Name = "get_blocked_process_reports"), Description("Blocked process report XE + DMV fallback events, newest first, window ends at as_of. not_collected wins if the engine can't run blocked_process_report; else empty means none in the window, or none collected in it. limit caps ROWS, not hours_back: truncated true means raise limit or narrow the window, not widen hours_back. wait_time_ms is milliseconds. Timestamps are UTC; last_tran/last_batch stamps are de-skewed for direct comparison to event_time. blocked_sql_text/blocking_sql_text are a preview by default (*_truncated: true) — pass full_text for the whole text. <> Gets detailed blocked process reports from extended events (parsed via sp_HumanEventsBlockViewer) plus the always-on DMV blocking-snapshot fallback, NEWEST FIRST. Provides detailed blocked/blocking session info: isolation levels, transaction names, full query text for both sessions. Use for deep analysis of prolonged blocking. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: hours_back is the window you ASKED for, reports_returned is how many rows you GOT, truncated says the window held more than limit, and oldest_returned_event_time / newest_returned_event_time bound the page you are looking at. Because the page is a contiguous newest-first slice, oldest_returned_event_time IS how far back this read reached — on a server blocking steadily, a 24-hour request at the default limit is answered by the newest few minutes, and nothing in the rows themselves says so. When truncated is true, raise limit or narrow hours_back (or anchor as_of) before drawing a conclusion about the window; widening hours_back cannot help, because the cap is on rows, not time. Every timestamp here is UTC: event_time already was, and the six blocked_/blocking_ last_tran/last_batch stamps are de-skewed from the monitored server's local clock by this read, so comparing them against event_time to see whether a transaction predates the block is direct.")]
public static async Task GetBlockedProcessReports(
LocalDataService dataService,
ServerManager serverManager,
[Description("Server name or display name.")] string? server_name = null,
[Description("Hours of history. Default 24.")] int hours_back = 24,
- [Description("Maximum rows to return, newest first. Default 30. This is what bounds the page — read truncated to know whether the window held more.")] int limit = 30,
+ [Description("Maximum rows to return, newest first. Default 15. This is what bounds the page — read truncated to know whether the window held more.")] int limit = 15,
+ [Description("Return each row's full blocked_sql_text/blocking_sql_text instead of a 150-character preview. Default false.")] bool full_text = false,
[Description(McpHelpers.AsOfDescription)] string? as_of = null)
{
var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name);
@@ -209,13 +216,15 @@ server blocking steadily was answered from its newest few minutes with nothing s
blocked_client_app = r.BlockedClientApp,
blocked_host_name = r.BlockedHostName,
blocked_login_name = r.BlockedLoginName,
- blocked_sql_text = McpHelpers.Truncate(r.BlockedSqlText, 2000),
+ blocked_sql_text = full_text ? r.BlockedSqlText : McpHelpers.Truncate(r.BlockedSqlText, SqlTextPreviewLength),
+ blocked_sql_text_truncated = !full_text && r.BlockedSqlText != null && r.BlockedSqlText.Length > SqlTextPreviewLength,
blocking_status = r.BlockingStatus,
blocking_isolation_level = r.BlockingIsolationLevel,
blocking_client_app = r.BlockingClientApp,
blocking_host_name = r.BlockingHostName,
blocking_login_name = r.BlockingLoginName,
- blocking_sql_text = McpHelpers.Truncate(r.BlockingSqlText, 2000),
+ blocking_sql_text = full_text ? r.BlockingSqlText : McpHelpers.Truncate(r.BlockingSqlText, SqlTextPreviewLength),
+ blocking_sql_text_truncated = !full_text && r.BlockingSqlText != null && r.BlockingSqlText.Length > SqlTextPreviewLength,
blocked_transaction_name = r.BlockedTransactionName,
blocking_transaction_name = r.BlockingTransactionName,
blocked_last_tran_started = r.BlockedLastTranStarted?.AddMinutes(-utcOffsetMinutes).ToString("o"),