diff --git a/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs b/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs
new file mode 100644
index 000000000..24cc6847a
--- /dev/null
+++ b/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs
@@ -0,0 +1,145 @@
+/*
+ * 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_active_queries' own response-budget pin. A busy production store's default call (limit 50,
+/// hours_back 1) measured 56,586 bytes. Unlike get_deadlock_detail's one wide field per row, this tool's
+/// overrun is TWENTY-THREE fields repeated across up to 50 rows: query_text is the widest single field, but
+/// the fixed columns (collection_time, every wait/blocking/memory field, login_name, host_name,
+/// program_name) add up across the page too. Plants 50 rows with realistic field widths — most query_text
+/// around 700 characters (a parameterized statement with a modest literal list), ten near 2,800 characters
+/// (a big IN-list, the realistic cause of an outsized capture) — and asserts the default call stays under
+/// , that full_text: true (renamed from
+/// full_query_text to match get_store_query_stats) opts back into the whole
+/// text. New file (not the shared seeding in DarlingMcpSessionToolsTests.cs) because #4198 ran a
+/// dozen lanes against this store tonight.
+///
+[Collection("live-postgres")]
+public sealed class DarlingMcpActiveQueriesBudgetLiveTests
+{
+ private const string ServerName = "darling-mcp-active-queries-budget-e2e";
+ private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName);
+ private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ private readonly ITestOutputHelper _output;
+
+ public DarlingMcpActiveQueriesBudgetLiveTests(ITestOutputHelper output) => _output = output;
+
+ [Fact]
+ public async Task GetActiveQueries_Default_StaysUnderResponseBudget_WithFiftyRealisticRows()
+ {
+ var cs = ConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live active-queries 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(-49);
+ var wideText = BuildQueryText(seed: 999, approxLength: 2_800);
+
+ for (var i = 0; i < 50; i++)
+ {
+ var t = DarlingMcpTestData.Naive(baseTime.AddMinutes(i));
+ var db = i % 3 == 0 ? "StackOverflow" : i % 3 == 1 ? "AdventureWorks" : "ReportingDW";
+ var isWide = i >= 40;
+ var text = isWide ? wideText : BuildQueryText(seed: i, approxLength: 700);
+ var hasWait = i % 4 == 0;
+
+ await DarlingMcpTestData.ExecAsync(connection, ct,
+ @"INSERT INTO query_snapshots (collection_id, collection_time, server_id, server_name, session_id, database_name, elapsed_time_formatted, query_text, status, blocking_session_id, wait_type, wait_time_ms, cpu_time_ms, total_elapsed_time_ms, reads, writes, logical_reads, granted_query_memory_gb, transaction_isolation_level, dop, parallel_worker_count, login_name, host_name, program_name, open_transaction_count, request_id)
+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)",
+ CollectionIdGenerator.Next(), t, ServerId, ServerName, 100 + i, db,
+ "00 00:00:05.125", text, i % 5 == 0 ? "suspended" : "running", 0,
+ hasWait ? "PAGEIOLATCH_SH" : null, hasWait ? 250L + i : 0L, 1000L + (i * 37), 1500L + (i * 37),
+ 10_000L + (i * 123), 50L + i, i % 7, i % 6 == 0 ? 0.75m : 0m,
+ i % 2 == 0 ? "Read Committed" : "Repeatable Read", i % 6 == 0 ? 8 : 1, i % 6 == 0 ? 4 : 0,
+ i % 2 == 0 ? "app_svc_prod" : @"CONTOSO\svc_reporting", $"APPSRV{i % 5:D2}",
+ i % 2 == 0 ? ".Net SqlClient Data Provider" : "MyOrderService.Worker", i % 3 == 0 ? 1 : 0, 0);
+ }
+
+ /* #4198's own default (25, down from 50) truncates the 50-row population — total_snapshots still
+ says 50 held, so a caller can tell there was more. */
+ var defaultJson = await DarlingMcpSessionTools.GetActiveQueries(postgres, ServerName);
+ DarlingMcpTestData.AssertEnvelope(defaultJson, ServerName, "queries");
+ JsonAssert.Contains("\"total_snapshots\": 50", defaultJson);
+ JsonAssert.Contains("\"snapshots_returned\": 25", defaultJson);
+ JsonAssert.Contains("\"truncated\": true", defaultJson);
+
+ var defaultBytes = Encoding.UTF8.GetByteCount(defaultJson);
+ _output.WriteLine($"get_active_queries default call: {defaultBytes:N0} bytes (budget {McpResponseBudget.DefaultBytes:N0}), 50 planted rows (page 25), 10 with a {wideText.Length:N0}-char query_text.");
+ Assert.True(defaultBytes < McpResponseBudget.DefaultBytes,
+ $"get_active_queries' default call is {defaultBytes:N0} bytes over 50 planted rows, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget.");
+
+ using var defaultParsed = JsonDocument.Parse(defaultJson);
+ var wideRow = defaultParsed.RootElement.GetProperty("queries")[0];
+ Assert.True(wideRow.GetProperty("query_text").GetString()!.Length < wideText.Length,
+ "the default call's widest row should preview shorter than the planted text.");
+ Assert.True(wideRow.GetProperty("query_text_truncated").GetBoolean());
+
+ /* full_text opts back into the whole text for every row, including the newest (widest) one. */
+ var fullJson = await DarlingMcpSessionTools.GetActiveQueries(postgres, ServerName, full_text: true);
+ Assert.DoesNotContain("\"query_text_truncated\": true", fullJson, StringComparison.Ordinal);
+ using var fullParsed = JsonDocument.Parse(fullJson);
+ Assert.Equal(wideText, fullParsed.RootElement.GetProperty("queries")[0].GetProperty("query_text").GetString());
+
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =>
+ await DeleteRowsAsync(cleanup, cleanupCt));
+ }
+ }
+
+ /// Builds ASCII SQL text (a big literal IN-list, a realistic cause of an outsized capture) near
+ /// characters, so its length in .NET UTF-16 chars and its size in UTF-8
+ /// bytes stay close.
+ private static string BuildQueryText(int seed, int approxLength)
+ {
+ var sb = new StringBuilder();
+ sb.Append("SELECT o.OrderId, o.CustomerId, o.OrderDate, o.TotalAmount, c.CustomerName, c.Region FROM dbo.Orders o JOIN dbo.Customers c ON c.CustomerId = o.CustomerId WHERE o.OrderDate >= '2026-01-01' AND o.Status IN (");
+ var i = 0;
+ while (sb.Length < approxLength)
+ {
+ sb.Append(seed * 100_000 + i);
+ sb.Append(',');
+ i++;
+ }
+
+ sb.Append(") ORDER BY o.OrderDate DESC;");
+ return sb.ToString();
+ }
+
+ private static async Task DeleteRowsAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct)
+ {
+ using var cleanup = new NpgsqlCommand(
+ $"DELETE FROM query_snapshots WHERE server_id = {ServerId}; DELETE FROM servers WHERE server_id = {ServerId};",
+ connection);
+ await cleanup.ExecuteNonQueryAsync(ct);
+ }
+}
diff --git a/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs b/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs
index b77b656ab..f4a363786 100644
--- a/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs
+++ b/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs
@@ -69,7 +69,7 @@ private static (string Name, bool Optional)[] McpParams(string toolName)
[Theory]
[InlineData("get_session_stats", "server_name")]
- [InlineData("get_active_queries", "server_name,hours_back,database_name,blocking_only,limit,as_of")]
+ [InlineData("get_active_queries", "server_name,hours_back,database_name,blocking_only,limit,full_text,as_of")]
[InlineData("get_waiting_tasks", "server_name,hours_back,limit,as_of")]
public void ParamContract_MatchesLite(string toolName, string expectedCsv)
{
diff --git a/Darling/Darling.Tests/DarlingWebEndpointsTests.cs b/Darling/Darling.Tests/DarlingWebEndpointsTests.cs
index 62ff2cd5a..ad6fcc404 100644
--- a/Darling/Darling.Tests/DarlingWebEndpointsTests.cs
+++ b/Darling/Darling.Tests/DarlingWebEndpointsTests.cs
@@ -126,6 +126,21 @@ public void ReadEndpoints_IncludeTheAgHealthTool()
Assert.Contains("get_ag_health", DarlingWebEndpoints.BuildReadDispatch().Keys);
}
+ [Fact]
+ public void ReadEndpoints_ActiveQueries_KeepsTheTwoThousandCharacterWebPreview()
+ {
+ /* #4198 lane W2: the MCP default fell to a 500-char query_text preview (QueryTextPreviewLength), but
+ the web viewer isn't that budget's caller — its /api/read row calls the internal budget-taking
+ overload with an explicit 2000, the pre-#4198 McpHelpers.Truncate budget every caller got, so the
+ Active Queries tab doesn't shrink under it. A regression here (dropping the overload, or the literal
+ 2000) silently starves that tab's query text down to 500 characters. Source-text pin rather than a
+ live call: no rig in this lane. */
+ var source = RepoFile.ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingWebEndpoints.cs");
+ Assert.Contains(
+ "[\"get_active_queries\"] = (c, pg, an) => DarlingMcpSessionTools.GetActiveQueries(pg, Server(c), Hours(c, 1), Str(c, \"database_name\"), QueryBool(c, \"blocking_only\", false), Rows(c, \"limit\", 50), 2000, AsOf(c)),",
+ source, StringComparison.Ordinal);
+ }
+
/* ── response-kind mapping (the error envelope -> 500, the invalid envelope -> 400 as the body, the '{'-sniff -> 200, miss envelope -> 200) ── */
[Theory]
diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs
index efdf19236..15042e0c2 100644
--- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs
+++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs
@@ -1475,12 +1475,13 @@ public void TheSharedTimestampParsers_RefuseWhatTheGeneralParserAccepts()
/// cut BEFORE the store, by the collector. They keep their names because they are true and different: a
/// caller can do nothing about them by re-paging, and folding them into truncated would tell that
/// caller to raise a limit that changes nothing.
- /// - A field-level response-budget preview — : #4198 sizes
+ ///
- A field-level response-budget preview — : #4198 sizes
/// each tool's DEFAULT answer under the shared 32 KB McpResponseBudget.DefaultBytes by previewing
/// one wide field (query text, a plan fragment, a deadlock graph) rather than the page — unlike a
/// source-side cut, a caller CAN get the rest, with an opt-in argument (get_deadlock_detail's
- /// full_graph, the same shape get_store_query_stats' full_text already used) or by
- /// narrowing the page to one named thing (a dedup_key call always gets the whole field).
+ /// full_graph; get_active_queries and get_store_query_stats both take
+ /// full_text, the same name — get_active_queries' own was renamed from full_query_text to
+ /// match).
/// - The withheld summary — : #3594's own vocabulary for a
/// reach verdict that withholds a figure rather than publishing a page's count under a whole's name.
///
@@ -1529,8 +1530,8 @@ public static readonly (string Key, string[] Files, string WhatWasCut)[] FieldPr
[
("deadlock_graph_xml_truncated", ["DarlingMcpBlockingTools.cs", "McpBlockingTools.cs"],
"#4198: get_deadlock_detail's own wide field — deadlock_graph_xml is a 2000-character preview by default (a busy production store measured 120,454 bytes for 3 graphs), full_graph or a dedup_key call gets the whole XML"),
- ("query_text_truncated", ["DarlingMcpPlanCorrectionTools.cs", "McpPlanCorrectionTools.cs"],
- "the row's own query_text previewed at READ TIME to QueryTextPreviewLength (150 chars) for #4198's response-size budget - the full text IS in the store (plan_correction.query_text is not collector-capped the way SourceSideCutKeys' rows are) and a caller gets it back by passing full_text=true, the opt-in get_store_query_stats already offers for its own preview"),
+ ("query_text_truncated", ["DarlingMcpPlanCorrectionTools.cs", "DarlingMcpSessionTools.cs", "McpPlanCorrectionTools.cs", "McpSessionTools.cs"],
+ "#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)"),
("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/DarlingMcpSessionTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt
index 4f725a71e..b84a88435 100644
--- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt
+++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt
@@ -3,8 +3,9 @@ tool get_active_queries 616
param get_active_queries.as_of 167
param get_active_queries.blocking_only 134
param get_active_queries.database_name 156
+param get_active_queries.full_text 84
param get_active_queries.hours_back 37
-param get_active_queries.limit 149
+param get_active_queries.limit 194
param get_active_queries.server_name 28
tool get_session_stats 391
diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs
index 7ca417caa..01e9a3aa4 100644
--- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs
+++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs
@@ -99,15 +99,24 @@ under the shared response budget. */
so neither counts here. */
/* #4192/#4195/#4193/#4217: audit_config narrowed, regression baseline bounded, PG CPU bucketed.
+82 bytes net after trimming. */
+
+ /* #4198 (lane TH): +193 bytes for get_active_queries' new full_query_text opt-in parameter and its
+ limit description's #4198 note (query_text, the wide field, is now a 500-char preview by default;
+ limit is 25, down from 50); the preview note itself moved after <> to stay under the head's
+ own 620-char target, so the served head is unchanged at 616. */
+ /* #4198 (lane W2): -6 bytes for renaming get_active_queries' full_query_text opt-in to full_text — the
+ shorter property name in the served schema, not a description change (the param's own description
+ byte count is unchanged at 84). Lowered to the measured total, banking the saving. */
+ /* #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 (per-tool lane, get_object_locking): +79 bytes for the new limit parameter (default lowered from
a 200-row hard cap to 75, measured under McpResponseBudget.DefaultBytes on a seeded fixture). Merged
with origin/dev's own #4192/#4195/#4193/#4217 bump above; the constant below is the measured total
with both changes applied, not the two deltas added by hand. */
- /* #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). */
- private const int TotalCeilingBytes = 172_446;
-
+ /* #4198 (lane TH, merge with get_object_locking): re-measured after merging origin/dev; combined total
+ of active_queries (#4261) + object_locking (#4258) changes on top of dev. */
+ private const int TotalCeilingBytes = 172_632;
private const int ConvertedHeadCap = 1_000;
private const int ConvertedParameterCap = 200;
diff --git a/Darling/Darling.Tests/RepoFileAdoptionTests.cs b/Darling/Darling.Tests/RepoFileAdoptionTests.cs
index a34a54c7f..0abf146c3 100644
--- a/Darling/Darling.Tests/RepoFileAdoptionTests.cs
+++ b/Darling/Darling.Tests/RepoFileAdoptionTests.cs
@@ -199,6 +199,10 @@ the CODE shape is told apart from the same words inside the comments beside it.
///
private static readonly string[] s_lfSubjects =
{
+ /* #4198 (lane W2): its source-text pin asserts a single-line entry in the endpoint dispatch
+ table (the `get_active_queries` row in DarlingWebEndpoints.cs) — the pin contains no line
+ break, so LF normalization does not affect what the assertion can match. */
+ "DarlingWebEndpointsTests.cs",
/* The equivalence test FOR the reader, parameterised over both spellings and calling each of them
directly. It compares the reader's output against the bytes on disk put through the same
transform, so it performs the CRLF-to-LF normalisation itself rather than depending on the
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
index d23694ed1..aeeac6800 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
@@ -2607,7 +2607,7 @@ internal static IReadOnlyDictionary BuildReadDispatch(I
["get_analysis_findings"] = (c, pg, an) => DarlingMcpTools.GetAnalysisFindings(an, pg, Server(c), Hours(c, 24), QueryBool(c, "include_drilldown", false), as_of: AsOf(c)),
/* ── sessions ── */
- ["get_active_queries"] = (c, pg, an) => DarlingMcpSessionTools.GetActiveQueries(pg, Server(c), Hours(c, 1), Str(c, "database_name"), QueryBool(c, "blocking_only", false), Rows(c, "limit", 50), as_of: AsOf(c)),
+ ["get_active_queries"] = (c, pg, an) => DarlingMcpSessionTools.GetActiveQueries(pg, Server(c), Hours(c, 1), Str(c, "database_name"), QueryBool(c, "blocking_only", false), Rows(c, "limit", 50), 2000, AsOf(c)),
["get_session_stats"] = (c, pg, an) => DarlingMcpSessionTools.GetSessionStats(pg, Server(c)),
["get_waiting_tasks"] = (c, pg, an) => DarlingMcpSessionTools.GetWaitingTasks(pg, Server(c), Hours(c, 1), Rows(c, "limit", 30), as_of: AsOf(c)),
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs
index 680db874d..217bff482 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs
@@ -92,15 +92,53 @@ public static async Task GetSessionStats(
}
}
- [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocking has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")]
- public static async Task GetActiveQueries(
+ ///
+ /// #4198: two things make get_active_queries' default call wide, not one dominant field the way
+ /// get_deadlock_detail's deadlock_graph_xml is. This row has TWENTY-THREE fields, so the fixed columns
+ /// alone (collection_time, every wait/blocking/memory field, login_name, host_name, program_name) run
+ /// about 700 bytes/row before query_text is counted — 50 of them is already near the budget on an
+ /// empty query_text. A busy production store's default call (limit 50, hours_back 1) measured 56,586
+ /// bytes; a synthetic 50-row page shaped like a busy server (every optional field populated, a fifth of
+ /// the rows carrying a long literal list) measured 81,489. So both come down: the default row limit to
+ /// , and query_text previews to
+ /// (full_text opts back in — #4198 renamed this from full_query_text to match
+ /// get_store_query_stats' full_text and get_deadlock_detail's full_graph,
+ /// the shape those already use). truncated / total_snapshots
+ /// already tell a caller who wants more to raise limit or narrow hours_back. The web viewer is not this
+ /// budget's caller (#4198 lane W2): its /api/read mirror keeps the pre-#4198 2,000-character
+ /// preview every caller got before this tool's default fell to 500, through the internal overload below
+ /// (the split #3897's trend tools use — one MCP-facing method, one budget-taking overload).
+ ///
+ private const int QueryTextPreviewLength = 500;
+
+ ///
+ private const int DefaultLimit = 25;
+
+ [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> query_text is a preview by default (query_text_truncated: true) — pass full_text for the whole text. Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocking has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")]
+ public static Task GetActiveQueries(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
[Description("Hours of data to retrieve. Default 1.")] int hours_back = 1,
[Description("Filter to a specific database. Applied in SQL; a head blocker in ANOTHER database is then not on the page, and its victims say blocker_not_shown = filtered.")] string? database_name = null,
[Description("Show only queries involved in blocking: rows with blocking_session_id > 0, plus the head blockers those rows name in the same capture.")] bool blocking_only = false,
- [Description("Maximum number of rows to return. Default 50. The page is bounded by limit, not by hours_back — truncated says whether the filtered window held more.")] int limit = 50,
- [Description(McpHelpers.AsOfDescription)] string? as_of = null)
+ [Description("Maximum number of rows to return. Default 25 (#4198, down from 50 — sized to fit the response budget). The page is bounded by limit, not hours_back — truncated says whether the window held more.")] int limit = DefaultLimit,
+ [Description("Return each row's full query text instead of a 500-character preview. Default false.")] bool full_text = false,
+ [Description(McpHelpers.AsOfDescription)] string? as_of = null) =>
+ GetActiveQueries(postgres, server_name, hours_back, database_name, blocking_only, limit,
+ full_text ? null : QueryTextPreviewLength, as_of);
+
+ ///
+ /// get_active_queries under an explicit query-text preview length (#4198 lane W2): the MCP tool above
+ /// passes , or null when its full_text opt-in (#4198, renamed from
+ /// full_query_text to match get_store_query_stats' full_text and the rest of the #4198 tools) asks for
+ /// the whole text, and the web viewer's /api/read mirror passes 2000 (the pre-#4198 budget every
+ /// caller got, sized for a page that redraws every 30 seconds rather than a model's context). One body,
+ /// so the two surfaces differ only in preview length; null means no truncation at all, not "unbounded
+ /// preview length" — the ternary below never truncates on a null budget.
+ ///
+ internal static async Task GetActiveQueries(
+ NpgsqlDataSource postgres, string? server_name, int hours_back, string? database_name, bool blocking_only,
+ int limit, int? queryTextPreviewLength, string? as_of)
{
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name);
if (error != null) return error;
@@ -173,7 +211,8 @@ database_name or blocking_only means the window held no snapshot matching them.
login_name = r.LoginName,
host_name = r.HostName,
program_name = r.ProgramName,
- query_text = McpHelpers.Truncate(r.QueryText, 2000)
+ query_text = queryTextPreviewLength.HasValue ? McpHelpers.Truncate(r.QueryText, queryTextPreviewLength.Value) : r.QueryText,
+ query_text_truncated = queryTextPreviewLength.HasValue && (r.QueryText?.Length ?? 0) > queryTextPreviewLength.Value
}).ToList();
return JsonSerializer.Serialize(new
diff --git a/Lite.Tests/McpPageContractTests.cs b/Lite.Tests/McpPageContractTests.cs
index 2e9dbd4de..0a24fc823 100644
--- a/Lite.Tests/McpPageContractTests.cs
+++ b/Lite.Tests/McpPageContractTests.cs
@@ -620,6 +620,85 @@ page and its victim says so. */
Assert.StartsWith("Invalid hours_back value '-1'", McpHelpers.ErrorMessageOf(await McpHealthTools.GetBlockingStats(_dataService, _serverManager, ServerName, -1)), StringComparison.Ordinal);
}
+ ///
+ /// #4198: get_active_queries' own response-budget pin. Darling's twin
+ /// (DarlingMcpActiveQueriesBudgetLiveTests) measured 81,489 bytes for a synthetic 50-row page
+ /// shaped like a busy server (every optional field populated, a fifth of the rows carrying a long
+ /// literal list) — this tool has TWENTY-THREE fields per row rather than one dominant wide field, so the
+ /// fixed columns add up across the page even before query_text is counted. Plants the same shape and
+ /// asserts the default call (limit down to 25) stays under ,
+ /// and that full_text: true (renamed from full_query_text to match
+ /// get_store_query_stats) opts back into the whole text.
+ ///
+ [Fact]
+ public async Task GetActiveQueries_Default_StaysUnderResponseBudget_WithFiftyRealisticRows()
+ {
+ var baseTime = WholeSecondsNow().AddMinutes(-49);
+ var wideText = BuildQueryText(seed: 999, approxLength: 2_800);
+
+ for (var i = 0; i < 50; i++)
+ {
+ var t = baseTime.AddMinutes(i);
+ var db = i % 3 == 0 ? "StackOverflow" : i % 3 == 1 ? "AdventureWorks" : "ReportingDW";
+ var text = i >= 40 ? wideText : BuildQueryText(seed: i, approxLength: 700);
+ var hasWait = i % 4 == 0;
+
+ await ExecAsync(@"
+INSERT INTO query_snapshots
+ (collection_id, collection_time, server_id, server_name, session_id, database_name, elapsed_time_formatted, query_text, status,
+ blocking_session_id, wait_type, wait_time_ms, cpu_time_ms, total_elapsed_time_ms, reads, writes, logical_reads,
+ granted_query_memory_gb, transaction_isolation_level, dop, parallel_worker_count, login_name, host_name, program_name, open_transaction_count)
+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)",
+ _nextId--, Naive(t), _serverId, ServerName, 100 + i, db,
+ "00 00:00:05.125", text, i % 5 == 0 ? "suspended" : "running", 0,
+ hasWait ? "PAGEIOLATCH_SH" : null, hasWait ? 250L + i : 0L, 1000L + (i * 37), 1500L + (i * 37),
+ 10_000L + (i * 123), 50L + i, i % 7, i % 6 == 0 ? 0.75 : 0,
+ i % 2 == 0 ? "Read Committed" : "Repeatable Read", i % 6 == 0 ? 8 : 1, i % 6 == 0 ? 4 : 0,
+ i % 2 == 0 ? "app_svc_prod" : @"CONTOSO\svc_reporting", $"APPSRV{i % 5:D2}",
+ i % 2 == 0 ? ".Net SqlClient Data Provider" : "MyOrderService.Worker", i % 3 == 0 ? 1 : 0);
+ }
+
+ /* Not AssertPage: get_active_queries' total_snapshots is a deliberate exception to "no total_* key"
+ (#3541 A13) — the FILTERED population's own size, not a window count. */
+ var defaultJson = await McpSessionTools.GetActiveQueries(_dataService, _serverManager, ServerName);
+ var root = Parse(defaultJson);
+ Assert.Equal(25, root.GetProperty("queries").GetArrayLength());
+ Assert.Equal(25, root.GetProperty("snapshots_returned").GetInt32());
+ Assert.Equal(50, root.GetProperty("total_snapshots").GetInt64());
+ Assert.True(root.GetProperty("truncated").GetBoolean());
+
+ var defaultBytes = System.Text.Encoding.UTF8.GetByteCount(defaultJson);
+ Assert.True(defaultBytes < McpResponseBudget.DefaultBytes,
+ $"get_active_queries' default call is {defaultBytes:N0} bytes over 50 planted rows, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget.");
+
+ var wideRow = root.GetProperty("queries")[0];
+ Assert.True(wideRow.GetProperty("query_text").GetString()!.Length < wideText.Length);
+ Assert.True(wideRow.GetProperty("query_text_truncated").GetBoolean());
+
+ var fullJson = await McpSessionTools.GetActiveQueries(_dataService, _serverManager, ServerName, full_text: true);
+ var fullRoot = Parse(fullJson);
+ Assert.False(fullRoot.GetProperty("queries")[0].GetProperty("query_text_truncated").GetBoolean());
+ Assert.Equal(wideText, fullRoot.GetProperty("queries")[0].GetProperty("query_text").GetString());
+ }
+
+ /// Builds ASCII SQL text (a big literal IN-list, a realistic cause of an outsized capture) near
+ /// characters, so its length and its UTF-8 byte count stay close.
+ private static string BuildQueryText(int seed, int approxLength)
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.Append("SELECT o.OrderId, o.CustomerId, o.OrderDate, o.TotalAmount, c.CustomerName, c.Region FROM dbo.Orders o JOIN dbo.Customers c ON c.CustomerId = o.CustomerId WHERE o.OrderDate >= '2026-01-01' AND o.Status IN (");
+ var i = 0;
+ while (sb.Length < approxLength)
+ {
+ sb.Append(seed * 100_000 + i);
+ sb.Append(',');
+ i++;
+ }
+
+ sb.Append(") ORDER BY o.OrderDate DESC;");
+ return sb.ToString();
+ }
+
/* ───────────────────────── #3739: a refusal carries a status word ───────────────────────── */
///
diff --git a/Lite.Tests/McpToolsListBudget/McpSessionTools.txt b/Lite.Tests/McpToolsListBudget/McpSessionTools.txt
index c3ba7c48c..e9060cc37 100644
--- a/Lite.Tests/McpToolsListBudget/McpSessionTools.txt
+++ b/Lite.Tests/McpToolsListBudget/McpSessionTools.txt
@@ -3,8 +3,9 @@ tool get_active_queries 616
param get_active_queries.as_of 167
param get_active_queries.blocking_only 134
param get_active_queries.database_name 156
+param get_active_queries.full_text 84
param get_active_queries.hours_back 37
-param get_active_queries.limit 149
+param get_active_queries.limit 194
param get_active_queries.server_name 28
tool get_session_stats 391
diff --git a/Lite.Tests/McpToolsListBudgetTests.cs b/Lite.Tests/McpToolsListBudgetTests.cs
index d6e41af3d..9cad0427c 100644
--- a/Lite.Tests/McpToolsListBudgetTests.cs
+++ b/Lite.Tests/McpToolsListBudgetTests.cs
@@ -83,6 +83,11 @@ public McpToolsListBudgetTests(ITestOutputHelper output)
/* #4199 (M2b): +162 bytes for get_collection_log's fleet-form server_name/limit descriptions, after
trimming both to the D2 200-char parameter cap and moving the rest to the tool's tail (get_tool_guide),
which is not served in tools/list and so is not counted here. Matches Darling's twin change exactly. */
+ /* #4198 (lane TH): +182 bytes for get_active_queries' new full_query_text opt-in parameter and its
+ limit description's #4198 note; the preview note itself moved after <> to stay under the
+ head's own 620-char target, so the served head is unchanged. Matches Darling's twin change. */
+ /* #4198 (lane W2): -6 bytes for renaming get_active_queries' full_query_text opt-in to full_text —
+ matches Darling's twin change. Lowered to the measured total, banking the saving. */
/* #4198 (get_query_heatmap): +147 bytes for the new full_text opt-in parameter, matching Darling's twin
change exactly. */
/* #4198 (per-tool lane, get_object_locking): +79 bytes for the new limit parameter (default lowered from
@@ -96,7 +101,7 @@ 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. */
- private const int TotalCeilingBytes = 90_497;
+ private const int TotalCeilingBytes = 90_672;
private const int ConvertedHeadCap = 1_000;
private const int ConvertedParameterCap = 200;
diff --git a/Lite/Mcp/McpSessionTools.cs b/Lite/Mcp/McpSessionTools.cs
index 27a7f8af3..5e6de9ad8 100644
--- a/Lite/Mcp/McpSessionTools.cs
+++ b/Lite/Mcp/McpSessionTools.cs
@@ -9,7 +9,14 @@ namespace PerformanceMonitorLite.Mcp;
[McpServerToolType]
public sealed class McpSessionTools
{
- [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocked_process_reports has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")]
+ /// #4198: see Darling's DarlingMcpSessionTools.QueryTextPreviewLength twin for the
+ /// measured bytes both defaults come down from.
+ private const int QueryTextPreviewLength = 500;
+
+ ///
+ private const int DefaultLimit = 25;
+
+ [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> query_text is a preview by default (query_text_truncated: true) — pass full_text for the whole text. Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocked_process_reports has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")]
public static async Task GetActiveQueries(
LocalDataService dataService,
ServerManager serverManager,
@@ -17,7 +24,8 @@ public static async Task GetActiveQueries(
[Description("Hours of data to retrieve. Default 1.")] int hours_back = 1,
[Description("Filter to a specific database. Applied in SQL; a head blocker in ANOTHER database is then not on the page, and its victims say blocker_not_shown = filtered.")] string? database_name = null,
[Description("Show only queries involved in blocking: rows with blocking_session_id > 0, plus the head blockers those rows name in the same capture.")] bool blocking_only = false,
- [Description("Maximum number of rows to return. Default 50. The page is bounded by limit, not by hours_back — truncated says whether the filtered window held more.")] int limit = 50,
+ [Description("Maximum number of rows to return. Default 25 (#4198, down from 50 — sized to fit the response budget). The page is bounded by limit, not hours_back — truncated says whether the window held more.")] int limit = DefaultLimit,
+ [Description("Return each row's full query text instead of a 500-character preview. Default false.")] bool full_text = false,
[Description(McpHelpers.AsOfDescription)] string? as_of = null)
{
var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name);
@@ -86,7 +94,8 @@ public static async Task GetActiveQueries(
login_name = r.LoginName,
host_name = r.HostName,
program_name = r.ProgramName,
- query_text = McpHelpers.Truncate(r.QueryText, 2000)
+ query_text = full_text ? r.QueryText : McpHelpers.Truncate(r.QueryText, QueryTextPreviewLength),
+ query_text_truncated = !full_text && (r.QueryText?.Length ?? 0) > QueryTextPreviewLength
}).ToList();
return JsonSerializer.Serialize(new