diff --git a/Darling/Darling.Tests/McpLatestSnapshotStampTests.cs b/Darling/Darling.Tests/McpLatestSnapshotStampTests.cs
index 2bac509ee..86046b4b5 100644
--- a/Darling/Darling.Tests/McpLatestSnapshotStampTests.cs
+++ b/Darling/Darling.Tests/McpLatestSnapshotStampTests.cs
@@ -741,20 +741,50 @@ private static string DescriptionOf(string body, string label)
further down can never be read as the tool's — which is exactly what a bare `Description(` search
did on the const-described tools when this file was first executed. */
var attribute = Regex.Match(body, @"\A\[McpServerTool\(Name = ""[a-z_0-9]+""\), Description\((?:\s*)(?:""((?:[^""\\]|\\.)*)""|(\w+))\)\]");
- Assert.True(attribute.Success, $"{label}: could not locate the tool's Description on its McpServerTool attribute");
- if (attribute.Groups[1].Success)
+ if (attribute.Success)
{
- return attribute.Groups[1].Value;
- }
+ if (attribute.Groups[1].Success)
+ {
+ return attribute.Groups[1].Value;
+ }
- var constName = attribute.Groups[2].Value;
+ var constName = attribute.Groups[2].Value;
- /* The const lives in the same file, above the attribute; the body slice starts AT the attribute, so it
- is not in the body — resolve it from the file the label points at. */
- var (type, toolName, liteFile, _) = LatestTools.Single(t => label.EndsWith(t.ToolName, StringComparison.Ordinal));
- return label.StartsWith("Darling", StringComparison.Ordinal)
- ? (string)type.GetField(constName, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static)!.GetRawConstantValue()!
- : LiteConstLiteral(liteFile, constName);
+ /* The const lives in the same file, above the attribute; the body slice starts AT the attribute, so
+ it is not in the body — resolve it from the file the label points at. */
+ var (type, toolName, liteFile, _) = LatestTools.Single(t => label.EndsWith(t.ToolName, StringComparison.Ordinal));
+ return label.StartsWith("Darling", StringComparison.Ordinal)
+ ? (string)type.GetField(constName, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static)!.GetRawConstantValue()!
+ : LiteConstLiteral(liteFile, constName);
+ }
+
+ /* #3898: a converted tool's Description is a "head" + McpToolGuide.Marker + "tail" concatenation —
+ neither a single literal nor a single const. Join every quoted literal chunk in source order,
+ skipping the identifiers between them (the marker, McpToolGuideTopics.* consts); this test only
+ needs the plain prose, never the reading-guide plumbing those identifiers resolve to. */
+ var anchor = Regex.Match(body, @"\A\[McpServerTool\(Name = ""[a-z_0-9]+""\), Description\(");
+ Assert.True(anchor.Success, $"{label}: could not locate the tool's Description on its McpServerTool attribute");
+ var i = anchor.Length;
+ var sb = new System.Text.StringBuilder();
+ while (i < body.Length && !(body[i] == ')' && i + 1 < body.Length && body[i + 1] == ']'))
+ {
+ if (body[i] == '"')
+ {
+ i++;
+ while (i < body.Length && body[i] != '"')
+ {
+ if (body[i] == '\\' && i + 1 < body.Length) { sb.Append(body[i + 1]); i += 2; }
+ else { sb.Append(body[i]); i++; }
+ }
+ i++;
+ }
+ else
+ {
+ i++;
+ }
+ }
+ Assert.True(sb.Length > 0, $"{label}: Description( had no literal text before its closing )]");
+ return sb.ToString();
}
/// A Lite const string's literal, read from source: internal const string Name =\n "…";.
diff --git a/Darling/Darling.Tests/McpPageContractTests.cs b/Darling/Darling.Tests/McpPageContractTests.cs
index f0d6e5545..fbab48584 100644
--- a/Darling/Darling.Tests/McpPageContractTests.cs
+++ b/Darling/Darling.Tests/McpPageContractTests.cs
@@ -330,7 +330,7 @@ public void EveryLitePagedTool_SaysWhatBoundsItsPage_InItsDescription()
foreach (var (_, _, liteFile, liteName) in PagedTools)
{
var body = ToolBody(ReadRepoFileLf(liteFile.Split('/')), liteName);
- var description = Regex.Match(body, @"Description\((?:\s*)""((?:[^""\\]|\\.)*)""\)\]").Groups[1].Value;
+ var description = DescriptionLiteralConcat(body);
Assert.False(string.IsNullOrEmpty(description), $"{liteFile}: could not locate {liteName}'s Description");
Assert.Contains("truncated", description, StringComparison.Ordinal);
Assert.Contains("limit", description, StringComparison.Ordinal);
@@ -1179,6 +1179,38 @@ private static string ToolBody(string source, string toolName)
return next < 0 ? source[start..] : source[start..next];
}
+ /// The tool's Description, from source: the FIRST Description( in a
+ /// slice is always the tool's own (a parameter's own [Description("...")] comes later, on the
+ /// method signature). #3898: a converted tool's Description is a "head" + McpToolGuide.Marker +
+ /// "tail" concatenation, not one literal, so this joins every quoted chunk up to the attribute's
+ /// closing )] and skips the identifiers between them (the marker, McpToolGuideTopics.* consts) —
+ /// this reads the prose only, never the reading-guide plumbing those identifiers resolve to.
+ private static string DescriptionLiteralConcat(string body)
+ {
+ var start = body.IndexOf("Description(", StringComparison.Ordinal);
+ Assert.True(start >= 0, "no Description( found in body");
+ var i = start + "Description(".Length;
+ var sb = new System.Text.StringBuilder();
+ while (i < body.Length && !(body[i] == ')' && i + 1 < body.Length && body[i + 1] == ']'))
+ {
+ if (body[i] == '"')
+ {
+ i++;
+ while (i < body.Length && body[i] != '"')
+ {
+ if (body[i] == '\\' && i + 1 < body.Length) { sb.Append(body[i + 1]); i += 2; }
+ else { sb.Append(body[i]); i++; }
+ }
+ i++;
+ }
+ else
+ {
+ i++;
+ }
+ }
+ return sb.ToString();
+ }
+
/// A non-tool member's span (LF source): from its static <type> Name( declaration to the
/// first line that is exactly the class-member closing brace. The wire-shape builders this file needs are
/// methods at member indent whose bodies close that way; a member that does not would return its first
diff --git a/Darling/Darling.Tests/McpToolGuideHeads.Data.cs b/Darling/Darling.Tests/McpToolGuideHeads.Data.cs
new file mode 100644
index 000000000..bd99ff727
--- /dev/null
+++ b/Darling/Darling.Tests/McpToolGuideHeads.Data.cs
@@ -0,0 +1,85 @@
+/*
+ * 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 PerformanceMonitor.Common;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #3898 D3 head pins for the "Data" family: the core resource/query/discovery tools converted out of
+/// DarlingMcpDataTools (Darling) and their Lite twins. Follows the pattern in
+/// .
+///
+public sealed class McpToolGuideHeadsDataTests
+{
+ private static readonly string[] ConvertedTools =
+ [
+ "get_file_io_stats",
+ "get_perfmon_stats",
+ "get_server_properties",
+ "get_top_procedures_by_cpu",
+ "get_top_queries_by_cpu",
+ "get_wait_stats",
+ "list_servers",
+ ];
+
+ /// The per-tool guardrail phrase each head must state.
+ private static readonly (string Tool, string Fact)[] HeadFacts =
+ [
+ ("get_file_io_stats", "LATEST IS A TIME: the newest snapshot, not a window"),
+ ("get_perfmon_stats", "LATEST IS A TIME: the newest snapshot, not a window"),
+ ("get_server_properties", "LATEST IS A TIME: the newest snapshot, not a window"),
+ ("get_top_procedures_by_cpu", "LIFETIME extremes, not windowed"),
+ ("get_top_queries_by_cpu", "LIFETIME extremes, not windowed"),
+ ("get_top_queries_by_cpu", "an empty page under it is the window's real answer, not a miss"),
+ ("get_wait_stats", "Bounded by limit"),
+ ("list_servers", "Darling has no live connection to monitored servers"),
+ ("list_servers", "Lite's status IS a live connection check"),
+ ];
+
+ [Fact]
+ public void EveryConvertedHead_CarriesItsGuardrailFact_AndThePointer()
+ {
+ foreach (var tool in ConvertedTools)
+ {
+ var served = McpToolGuideTests.Served(tool);
+ Assert.NotNull(served.Tail);
+ Assert.EndsWith(McpToolGuide.GuidePointer, served.Served, StringComparison.Ordinal);
+ Assert.True(served.Served.Length <= 620, $"{tool}: served head {served.Served.Length} is over the 620 target");
+ Assert.All(served.ParameterDescriptionLengths, p => Assert.True(p.Length <= 200, $"{tool}.{p.Parameter}: {p.Length} > 200"));
+ }
+
+ foreach (var (tool, fact) in HeadFacts)
+ {
+ Assert.Contains(fact, McpToolGuideTests.Served(tool).Served, StringComparison.Ordinal);
+ }
+ }
+
+ /// Nothing dropped: the shared cpu-extremes/attribution topic rides on both top_queries and
+ /// top_procedures tails, and the zero-semantics sentence each carries in its own tail.
+ [Fact]
+ public void CpuTimeExtremesTopic_RidesOnBothTopByCpuTools()
+ {
+ Assert.EndsWith(McpToolGuideTopics.CpuTimeExtremesAndAttribution, McpToolGuideTests.Served("get_top_procedures_by_cpu").Tail!, StringComparison.Ordinal);
+ Assert.EndsWith(McpToolGuideTopics.CpuTimeExtremesAndAttribution, McpToolGuideTests.Served("get_top_queries_by_cpu").Tail!, StringComparison.Ordinal);
+ Assert.Contains("distinct_texts", McpToolGuideTests.Served("get_top_queries_by_cpu").Tail!, StringComparison.Ordinal);
+ }
+
+ /// list_servers is D10's always-loaded entry tool; its Darling-only peer_fleets/engine_kind detail
+ /// lives in its own tail, never the shared head (D6: the head must be true on both SKUs).
+ [Fact]
+ public void ListServers_KeepsEngineAndPeerDetailOffTheSharedHead()
+ {
+ var served = McpToolGuideTests.Served("list_servers");
+ Assert.DoesNotContain("peer_fleets", served.Served, StringComparison.Ordinal);
+ Assert.DoesNotContain("engine_kind", served.Served, StringComparison.Ordinal);
+ Assert.Contains("peer_fleets", served.Tail!, StringComparison.Ordinal);
+ }
+}
diff --git a/Darling/Darling.Tests/McpToolGuideTests.cs b/Darling/Darling.Tests/McpToolGuideTests.cs
index a27da117f..99aecae6c 100644
--- a/Darling/Darling.Tests/McpToolGuideTests.cs
+++ b/Darling/Darling.Tests/McpToolGuideTests.cs
@@ -152,7 +152,7 @@ public void GetToolGuide_ServesTails_AndTopics_AndReportsUnknownNames()
var catalog = HostCatalog();
using var doc = JsonDocument.Parse(DarlingMcpToolGuideTools.GetToolGuide(
catalog,
- ["GET_HEALTH_PARSER_CPU_TASKS", "get_health_parser_cpu_tasks", "list_servers", "no_such_tool"],
+ ["GET_HEALTH_PARSER_CPU_TASKS", "get_health_parser_cpu_tasks", "get_cpu_utilization", "no_such_tool"],
["system_health_empty_windows", "no_such_topic"]));
var root = doc.RootElement;
@@ -161,7 +161,7 @@ public void GetToolGuide_ServesTails_AndTopics_AndReportsUnknownNames()
Assert.Single(tools);
Assert.Equal("get_health_parser_cpu_tasks", tools[0].GetProperty("name").GetString());
Assert.Equal(Served("get_health_parser_cpu_tasks").Tail, tools[0].GetProperty("guide").GetString());
- Assert.Equal(new[] { "list_servers" }, root.GetProperty("no_separate_guide").EnumerateArray().Select(e => e.GetString()).ToArray());
+ Assert.Equal(new[] { "get_cpu_utilization" }, root.GetProperty("no_separate_guide").EnumerateArray().Select(e => e.GetString()).ToArray());
Assert.Equal(new[] { "no_such_tool" }, root.GetProperty("unknown_tools").EnumerateArray().Select(e => e.GetString()).ToArray());
Assert.Equal(new[] { "no_such_topic" }, root.GetProperty("unknown_topics").EnumerateArray().Select(e => e.GetString()).ToArray());
Assert.Equal(McpToolGuideTopics.SystemHealthEmptyWindows, root.GetProperty("topics")[0].GetProperty("guide").GetString());
@@ -175,9 +175,12 @@ public void GetToolGuide_WithNoArguments_IsTheIndex()
about THIS test's host, not a family pin (that lives in McpToolGuideHeadsHealthParserTests). */
var fixtureToolsWithGuides = new[]
{
+ "get_file_io_stats",
"get_health_parser_cpu_tasks", "get_health_parser_io_issues", "get_health_parser_memory_broker",
"get_health_parser_memory_conditions", "get_health_parser_memory_node_oom", "get_health_parser_scheduler_issues",
"get_health_parser_severe_errors", "get_health_parser_significant_waits", "get_health_parser_system_health",
+ "get_perfmon_stats", "get_server_properties", "get_top_procedures_by_cpu", "get_top_queries_by_cpu",
+ "get_wait_stats", "list_servers",
};
using var doc = JsonDocument.Parse(DarlingMcpToolGuideTools.GetToolGuide(HostCatalog(), null, null));
@@ -342,6 +345,12 @@ private static (Dictionary Files, Dictionary HasMa
var end = idx + 1 < anchors.Count ? anchors[idx + 1].Index : src.Length;
Assert.True(files.TryAdd(name, file), $"duplicate MCP tool name '{name}' under {string.Join("/", segments)}");
hasMarker[name] = src[anchors[idx].Index..end].Contains(McpToolGuide.Marker, StringComparison.Ordinal);
+ /* The marker is written INSIDE the Description literal (D3). A concatenated McpToolGuide.Marker
+ compiles to the same string, but this scan (and `grep <>`) would read the tool as
+ unconverted, so the cross-SKU identity check above would silently skip it. */
+ Assert.False(
+ Regex.IsMatch(src[anchors[idx].Index..end], @"\+\s*McpToolGuide\.Marker|McpToolGuide\.Marker\s*\+"),
+ $"{name}: write {McpToolGuide.Marker} inside the Description literal; a concatenated McpToolGuide.Marker hides the tool from the source scans.");
}
}
diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt
index da470203c..96f8169a7 100644
--- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt
+++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt
@@ -28,7 +28,7 @@ param get_current_waits_trend.database_name 73
param get_current_waits_trend.hours_back 190
param get_current_waits_trend.server_name 28
-tool get_file_io_stats 690
+tool get_file_io_stats 437
param get_file_io_stats.server_name 28
tool get_memory_clerks 232
@@ -37,7 +37,7 @@ param get_memory_clerks.server_name 28
tool get_memory_stats 480
param get_memory_stats.server_name 28
-tool get_perfmon_stats 1034
+tool get_perfmon_stats 521
param get_perfmon_stats.counter_name 61
param get_perfmon_stats.instance_name 57
param get_perfmon_stats.server_name 28
@@ -49,7 +49,7 @@ param get_query_store_top.hours_back 29
param get_query_store_top.server_name 28
param get_query_store_top.top 34
-tool get_server_properties 894
+tool get_server_properties 571
param get_server_properties.server_name 28
tool get_tempdb_trend 254
@@ -58,24 +58,24 @@ param get_tempdb_trend.bucket_minutes 55
param get_tempdb_trend.hours_back 29
param get_tempdb_trend.server_name 28
-tool get_top_procedures_by_cpu 821
+tool get_top_procedures_by_cpu 406
param get_top_procedures_by_cpu.as_of 167
param get_top_procedures_by_cpu.database_name 30
param get_top_procedures_by_cpu.hours_back 29
param get_top_procedures_by_cpu.server_name 28
param get_top_procedures_by_cpu.top 37
-tool get_top_queries_by_cpu 1837
+tool get_top_queries_by_cpu 474
param get_top_queries_by_cpu.as_of 167
param get_top_queries_by_cpu.database_name 30
-param get_top_queries_by_cpu.group_by 616
+param get_top_queries_by_cpu.group_by 167
param get_top_queries_by_cpu.hours_back 29
param get_top_queries_by_cpu.min_dop 132
-param get_top_queries_by_cpu.parallel_only 371
+param get_top_queries_by_cpu.parallel_only 160
param get_top_queries_by_cpu.server_name 28
param get_top_queries_by_cpu.top 34
-tool get_wait_stats 644
+tool get_wait_stats 316
param get_wait_stats.as_of 167
param get_wait_stats.hours_back 40
param get_wait_stats.limit 145
@@ -93,4 +93,4 @@ param get_wait_types.as_of 167
param get_wait_types.hours_back 29
param get_wait_types.server_name 28
-tool list_servers 1228
+tool list_servers 382
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
index 2f593d6a3..50c81ccb5 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
@@ -98,7 +98,7 @@ internal static async Task GetCpuUtilization(
}
}
- [McpServerTool(Name = "get_wait_stats"), Description("Gets the top SQL Server wait types aggregated over a time period, heaviest total wait first. Wait stats reveal what SQL Server spends time waiting on — high signal waits indicate CPU pressure, high resource waits indicate I/O or lock contention. Use this first to identify the dominant wait category, then drill into specific tools based on the wait type. THE PAGE IS BOUNDED BY limit: wait_types_returned is how many wait types you got and truncated says the window observed more than limit — the rows you have are the heaviest, and the ones past the cap are lighter, but a sum over the page is a sum over the page rather than over the server.")]
+ [McpServerTool(Name = "get_wait_stats"), Description("Gets the top SQL Server wait types over a time period ending at as_of, heaviest total wait first. Bounded by limit: wait_types_returned is how many you got; truncated means the window held more. The page holds only the heaviest waits — a sum over the page is not a sum over the server. <> Gets the top SQL Server wait types aggregated over a time period, heaviest total wait first. Wait stats reveal what SQL Server spends time waiting on — high signal waits indicate CPU pressure, high resource waits indicate I/O or lock contention. Use this first to identify the dominant wait category, then drill into specific tools based on the wait type. THE PAGE IS BOUNDED BY limit: wait_types_returned is how many wait types you got and truncated says the window observed more than limit — the rows you have are the heaviest, and the ones past the cap are lighter, but a sum over the page is a sum over the page rather than over the server.")]
public static async Task GetWaitStats(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
@@ -372,7 +372,7 @@ with the read by construction and tell the caller nothing it did not already hav
}
}
- [McpServerTool(Name = "get_file_io_stats"), Description("Gets the latest file I/O statistics per database file: read/write counts, bytes, stall times, and calculated latency. High read latency (>20ms) or write latency (>10ms for data, >2ms for log) often indicates storage bottlenecks. Each row carries sample_interval_seconds, the measured seconds its deltas accrued over; a 0 means no delta was knowable for that file at this collection (first sighting, counter reset, or a gap past the delta policy — typically a restart) and its latencies are null rather than 0. LATEST IS A TIME: this reads the newest file-I/O snapshot, not a window, and captured_at is the instant it was collected; the deltas cover the sample_interval_seconds ending there.")]
+ [McpServerTool(Name = "get_file_io_stats"), Description("Gets the latest per-database-file I/O stats: read/write counts, bytes, stall times, calculated latency. LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and the deltas cover the sample_interval_seconds ending there. sample_interval_seconds 0 means no delta was knowable for that file (first sighting, counter reset, a gap) and that row's latencies are null, not 0. <> Gets the latest file I/O statistics per database file: read/write counts, bytes, stall times, and calculated latency. High read latency (>20ms) or write latency (>10ms for data, >2ms for log) often indicates storage bottlenecks. Each row carries sample_interval_seconds, the measured seconds its deltas accrued over; a 0 means no delta was knowable for that file at this collection (first sighting, counter reset, or a gap past the delta policy — typically a restart) and its latencies are null rather than 0. LATEST IS A TIME: this reads the newest file-I/O snapshot, not a window, and captured_at is the instant it was collected; the deltas cover the sample_interval_seconds ending there.")]
public static async Task GetFileIoStats(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null)
@@ -461,7 +461,7 @@ internal static async Task GetTempDbTrend(
}
}
- [McpServerTool(Name = "get_perfmon_stats"), Description("Gets the latest SQL Server performance counter values: batch requests/sec, compilations/sec, deadlocks/sec, and more. Provides throughput context to distinguish a busy server from a sick one. Use counter_name or instance_name to filter results. LATEST IS A TIME: this reads the newest counter snapshot, not a window, and captured_at is the instant it was collected; use get_perfmon_trend for a counter over time. Each row carries counter_kind from the stored cntr_type: 'gauge' means value IS the reading (a level such as Total Server Memory (KB); delta_value is null because a level has no delta), 'rate' means value is a cumulative count and delta_value is its change over the last collection interval (get_perfmon_trend carries the sample_interval_seconds to divide it by for a per-second figure), 'other' means an average/fraction numerator whose delta_value is a per-interval change and not a rate; null counter_kind is a row written before the type was stored — classify it by name (a counter whose name ends in /sec is a rate).")]
+ [McpServerTool(Name = "get_perfmon_stats"), Description("Gets the latest SQL Server performance counter values (batch requests/sec, compilations/sec, deadlocks/sec, and more). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected; use get_perfmon_trend for history. counter_kind: gauge = value IS the reading, delta_value null; rate = value is cumulative, delta_value its per-interval change; other = a non-rate per-interval change; null counter_kind predates the column, classify by name (ends in /sec = rate). <> Gets the latest SQL Server performance counter values: batch requests/sec, compilations/sec, deadlocks/sec, and more. Provides throughput context to distinguish a busy server from a sick one. Use counter_name or instance_name to filter results. LATEST IS A TIME: this reads the newest counter snapshot, not a window, and captured_at is the instant it was collected; use get_perfmon_trend for a counter over time. Each row carries counter_kind from the stored cntr_type: 'gauge' means value IS the reading (a level such as Total Server Memory (KB); delta_value is null because a level has no delta), 'rate' means value is a cumulative count and delta_value is its change over the last collection interval (get_perfmon_trend carries the sample_interval_seconds to divide it by for a per-second figure), 'other' means an average/fraction numerator whose delta_value is a per-interval change and not a rate; null counter_kind is a row written before the type was stored — classify it by name (a counter whose name ends in /sec is a rate).")]
public static async Task GetPerfmonStats(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
@@ -512,16 +512,16 @@ public static async Task GetPerfmonStats(
/* ═══════════════════════════ query performance ═══════════════════════════ */
- [McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive queries from sys.dm_exec_query_stats (plan cache). Best for: currently cached queries with detailed per-execution stats, DOP, spills, and query_hash for trending. Returns query_hash, query_plan_hash, sql_handle, plan_handle, and host_object (the hosting procedure/function for proc-hosted statements, null for ad-hoc) — groups key on (database, query_hash, host_object), so INSERT...EXEC callers in different procedures report separately with their own text. distinct_texts counts statement texts merged into a group (>1 = ad-hoc literal variants or pre-upgrade history; query_text is one representative, 0 means only rows predating the text dimension). Set group_by='host_object' to roll all of a procedure's statements into one row — necessary when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Supports database and parallelism filtering; every filter is applied IN the query before the ranking and the cap, so the page is the top-N of the FILTERED population (filter_applied names the parallelism floor in force, null when none), and an empty page under parallel_only/min_dop is the window's answer rather than a page artefact. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. Also returns cpu_attribution: the returned rows' summed CPU-seconds against the SQL process's measured CPU-seconds for the window (avg cpu_utilization % x core count x window) - attributed_cpu_ratio says how much of the box the ranking explains; when the CPU series or core count is missing, or covers too little of the window, the ratio is omitted rather than invented.")]
+ [McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive cached queries from sys.dm_exec_query_stats, ranked by CPU over a window ending at as_of. Filters (database_name, parallel_only, min_dop) apply before the top-N cap: filter_applied names the floor in force, and an empty page under it is the window's real answer, not a miss. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed; cpu_attribution's ratio is omitted, not invented, when its inputs are missing. <> Gets expensive queries from sys.dm_exec_query_stats (plan cache). Best for: currently cached queries with detailed per-execution stats, DOP, spills, and query_hash for trending. Returns query_hash, query_plan_hash, sql_handle, plan_handle, and host_object (the hosting procedure/function for proc-hosted statements, null for ad-hoc) — groups key on (database, query_hash, host_object), so INSERT...EXEC callers in different procedures report separately with their own text. distinct_texts counts statement texts merged into a group (>1 = ad-hoc literal variants or pre-upgrade history; query_text is one representative, 0 means only rows predating the text dimension). 'host_object' rolls all of a procedure's statements into one row — use it when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Ad-hoc statements have no host object and stay grouped per hash in both modes. distinct_query_hashes reports how many hashes a row rolled up. Set group_by='host_object' to roll all of a procedure's statements into one row — necessary when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Supports database and parallelism filtering; every filter is applied IN the query before the ranking and the cap, so the page is the top-N of the FILTERED population (filter_applied names the parallelism floor in force, null when none), and an empty page under parallel_only/min_dop is the window's answer rather than a page artefact. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. max_dop comes from sys.dm_exec_query_stats and is a lifetime-max for the plan's time in cache, so a plan compiled before MAXDOP was lowered keeps reporting the old higher value until it is evicted or recompiled; confirm current parallelism with analyze_query_plan, which reads the actual plan. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution)]
public static async Task GetTopQueriesByCpu(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
[Description("Hours of history. Default 24.")] int hours_back = 24,
[Description("Number of top queries. Default 20.")] int top = 20,
[Description("Filter to a specific database.")] string? database_name = null,
- [Description("If true, only return queries whose cached plan has EVER run at DOP > 1. Note: max_dop comes from sys.dm_exec_query_stats and is a lifetime-max for the plan's time in cache, so a plan compiled before MAXDOP was lowered keeps reporting the old higher value until it is evicted or recompiled. Confirm current parallelism with analyze_query_plan, which reads the actual plan.")] bool parallel_only = false,
+ [Description("If true, only return queries whose cached plan has EVER run at DOP > 1 (a LIFETIME max_dop; can read stale after a MAXDOP change). See the tool's reading guide.")] bool parallel_only = false,
[Description("Minimum DOP to filter on. Implies parallel filtering. Filters the same lifetime-max value as parallel_only, not current parallelism.")] int min_dop = 0,
- [Description("Grouping. 'query_hash' (default) is one row per (database, query_hash, host_object). 'host_object' rolls every statement of a hosting procedure/function into ONE row — use it when dynamic SQL built with per-value literals fragments one logical statement across many query_hash values, which makes top-N-by-hash structurally unable to surface it (measured at 21 fragments for one statement, whose combined CPU was the largest on the instance while no single fragment ranked). Ad-hoc statements have no host object and stay grouped per hash in both modes. distinct_query_hashes reports how many hashes a row rolled up.")] string group_by = "query_hash",
+ [Description("Grouping. 'query_hash' (default): one row per (database, query_hash, host_object). 'host_object': rolls a proc's statements into ONE row. See the tool's reading guide.")] string group_by = "query_hash",
[Description(McpHelpers.AsOfDescription)] string? as_of = null)
{
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name);
@@ -667,7 +667,7 @@ the two answer different questions and the rows look alike. */
}
}
- [McpServerTool(Name = "get_top_procedures_by_cpu"), Description("Gets the most expensive stored procedures ranked by total CPU time. Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. Also returns cpu_attribution: the returned rows' summed CPU-seconds against the SQL process's measured CPU-seconds for the window (avg cpu_utilization % x core count x window) - attributed_cpu_ratio says how much of the box the ranking explains; when the CPU series or core count is missing, or covers too little of the window, the ratio is omitted rather than invented.")]
+ [McpServerTool(Name = "get_top_procedures_by_cpu"), Description("Gets the most expensive stored procedures ranked by total CPU time over a window ending at as_of. Delta-based: requires ~30 minutes after adding a new server before data appears. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed (extremes_note flags a provably stale one); cpu_attribution's ratio is omitted, not invented, when its inputs are missing. <> Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution)]
public static async Task GetTopProceduresByCpu(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
@@ -859,7 +859,7 @@ it stops. The trend family renamed the same fact the same way (DarlingMcpTrendTo
/* ═══════════════════════════ discovery / health ═══════════════════════════ */
- [McpServerTool(Name = "list_servers"), Description("Lists all monitored servers — SQL Server and PostgreSQL — with their collection freshness status and last collection time. Use this first to see available servers before calling other tools. Each row says which engine it describes: engine_kind is the raw registry token (sqlserver, postgres, aurora-postgres; null when no connect has stamped the row — the same vocabulary get_fleet_overview uses) and engine_version is the engine-aware version label (\"SQL Server 2022\", \"PostgreSQL 18\"; empty when no version has been collected). sql_version is a DEPRECATED legacy alias carrying the same value as engine_version, kept so existing consumers keep working — read engine_version instead, and never infer the engine from that key's name: a PostgreSQL row's sql_version reads \"PostgreSQL 18\". The service has no live connection to the monitored servers, so status is derived from how recently each server was collected (Online = fresh, Warning = stale, Offline = no recent collection). The peer_fleets block names the SIBLING Darling stores that monitor the rest of a split fleet, with what each one covers — this server can only NAME them (no cross-store reads), and peer_note says what an empty peer_fleets does and does not prove.")]
+ [McpServerTool(Name = "list_servers"), Description("Lists monitored servers with collection freshness status and last collection time. Darling has no live connection to monitored servers: status is derived from how recently each was collected (Online = fresh, Warning = stale, Offline = no recent collection). Lite's status IS a live connection check, independent of whether anything is being collected. <> Use this first to see available servers before calling other tools. Lists all monitored servers — SQL Server and PostgreSQL — with their collection freshness status and last collection time. Each row says which engine it describes: engine_kind is the raw registry token (sqlserver, postgres, aurora-postgres; null when no connect has stamped the row — the same vocabulary get_fleet_overview uses) and engine_version is the engine-aware version label (\"SQL Server 2022\", \"PostgreSQL 18\"; empty when no version has been collected). sql_version is a DEPRECATED legacy alias carrying the same value as engine_version, kept so existing consumers keep working — read engine_version instead, and never infer the engine from that key's name: a PostgreSQL row's sql_version reads \"PostgreSQL 18\". The service has no live connection to the monitored servers, so status is derived from how recently each server was collected (Online = fresh, Warning = stale, Offline = no recent collection). The peer_fleets block names the SIBLING Darling stores that monitor the rest of a split fleet, with what each one covers — this server can only NAME them (no cross-store reads), and peer_note says what an empty peer_fleets does and does not prove.")]
public static async Task ListServers(
NpgsqlDataSource postgres)
{
@@ -1390,7 +1390,7 @@ column of the statement behind it follows. */
}
}
- [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is.")]
+ [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, memory, socket/core topology, HADR, clustering, and the clock (utc_offset_minutes, time_zone_id). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and on a stalled collector it is the only sign of staleness. time_zone_id is CURRENT_TIMEZONE_ID() (SQL Server 2022+/Azure SQL only); null means a pre-2022 engine, so only the offset in force at captured_at is known, and an instant across a DST transition from it can read an hour off. <> Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is.")]
public static async Task GetServerProperties(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null)
diff --git a/Lite.Tests/McpToolGuideHeads.Data.cs b/Lite.Tests/McpToolGuideHeads.Data.cs
new file mode 100644
index 000000000..364665a7d
--- /dev/null
+++ b/Lite.Tests/McpToolGuideHeads.Data.cs
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using PerformanceMonitor.Common;
+using Xunit;
+
+namespace Lite.Tests;
+
+///
+/// #3898 D3 head pins for the "Data" family's Lite twins. Darling's twin is
+/// Darling.Tests/McpToolGuideHeadsDataTests, which also holds the cross-SKU lockstep pin.
+///
+public sealed class McpToolGuideHeadsDataTests
+{
+ private static readonly string[] ConvertedTools =
+ [
+ "get_file_io_stats",
+ "get_perfmon_stats",
+ "get_server_properties",
+ "get_top_procedures_by_cpu",
+ "get_top_queries_by_cpu",
+ "get_wait_stats",
+ "list_servers",
+ ];
+
+ private static readonly (string Tool, string Fact)[] HeadFacts =
+ [
+ ("get_file_io_stats", "LATEST IS A TIME: the newest snapshot, not a window"),
+ ("get_perfmon_stats", "LATEST IS A TIME: the newest snapshot, not a window"),
+ ("get_server_properties", "LATEST IS A TIME: the newest snapshot, not a window"),
+ ("get_top_procedures_by_cpu", "LIFETIME extremes, not windowed"),
+ ("get_top_queries_by_cpu", "LIFETIME extremes, not windowed"),
+ ("get_wait_stats", "Bounded by limit"),
+ ("list_servers", "Lite's status IS a live connection check"),
+ ];
+
+ [Fact]
+ public void EveryConvertedHead_CarriesItsGuardrailFact_AndThePointer()
+ {
+ foreach (var tool in ConvertedTools)
+ {
+ var served = McpToolGuideTests.Served(tool);
+ Assert.NotNull(served.Tail);
+ Assert.EndsWith(McpToolGuide.GuidePointer, served.Served, StringComparison.Ordinal);
+ Assert.True(served.Served.Length <= 620, $"{tool}: served head {served.Served.Length} is over the 620 target");
+ Assert.All(served.ParameterDescriptionLengths, p => Assert.True(p.Length <= 200, $"{tool}.{p.Parameter}: {p.Length} > 200"));
+ }
+
+ foreach (var (tool, fact) in HeadFacts)
+ {
+ Assert.Contains(fact, McpToolGuideTests.Served(tool).Served, StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public void CpuTimeExtremesTopic_RidesOnBothTopByCpuTools()
+ {
+ Assert.EndsWith(McpToolGuideTopics.CpuTimeExtremesAndAttribution, McpToolGuideTests.Served("get_top_procedures_by_cpu").Tail!, StringComparison.Ordinal);
+ Assert.EndsWith(McpToolGuideTopics.CpuTimeExtremesAndAttribution, McpToolGuideTests.Served("get_top_queries_by_cpu").Tail!, StringComparison.Ordinal);
+ }
+
+ /// Lite's list_servers has no fleet/engine concept; its tail is just the original "use this first"
+ /// sentence, never the Darling-only peer_fleets/engine_kind prose (that lives only in Darling's own tail).
+ [Fact]
+ public void ListServers_TailHasNoDarlingOnlyFleetDetail()
+ {
+ var served = McpToolGuideTests.Served("list_servers");
+ Assert.DoesNotContain("peer_fleets", served.Served, StringComparison.Ordinal);
+ Assert.DoesNotContain("peer_fleets", served.Tail!, StringComparison.Ordinal);
+ }
+}
diff --git a/Lite.Tests/McpToolGuideTests.cs b/Lite.Tests/McpToolGuideTests.cs
index da4a950bb..1b8298be0 100644
--- a/Lite.Tests/McpToolGuideTests.cs
+++ b/Lite.Tests/McpToolGuideTests.cs
@@ -89,7 +89,7 @@ public void GetToolGuide_ServesTails_AndTopics_AndReportsUnknownNames()
var catalog = HostCatalog();
using var doc = JsonDocument.Parse(McpToolGuideTools.GetToolGuide(
catalog,
- ["GET_HEALTH_PARSER_CPU_TASKS", "get_health_parser_cpu_tasks", "list_servers", "no_such_tool"],
+ ["GET_HEALTH_PARSER_CPU_TASKS", "get_health_parser_cpu_tasks", "no_such_tool"],
["system_health_empty_windows", "no_such_topic"]));
var root = doc.RootElement;
@@ -98,7 +98,6 @@ public void GetToolGuide_ServesTails_AndTopics_AndReportsUnknownNames()
Assert.Single(tools);
Assert.Equal("get_health_parser_cpu_tasks", tools[0].GetProperty("name").GetString());
Assert.Equal(Served("get_health_parser_cpu_tasks").Tail, tools[0].GetProperty("guide").GetString());
- Assert.Equal(new[] { "list_servers" }, root.GetProperty("no_separate_guide").EnumerateArray().Select(e => e.GetString()).ToArray());
Assert.Equal(new[] { "no_such_tool" }, root.GetProperty("unknown_tools").EnumerateArray().Select(e => e.GetString()).ToArray());
Assert.Equal(new[] { "no_such_topic" }, root.GetProperty("unknown_topics").EnumerateArray().Select(e => e.GetString()).ToArray());
Assert.Equal(McpToolGuideTopics.SystemHealthEmptyWindows, root.GetProperty("topics")[0].GetProperty("guide").GetString());
@@ -115,6 +114,7 @@ family pin (that lives in McpToolGuideHeadsHealthParserTests). */
"get_health_parser_cpu_tasks", "get_health_parser_io_issues", "get_health_parser_memory_broker",
"get_health_parser_memory_conditions", "get_health_parser_memory_node_oom", "get_health_parser_scheduler_issues",
"get_health_parser_severe_errors", "get_health_parser_significant_waits", "get_health_parser_system_health",
+ "list_servers",
};
using var doc = JsonDocument.Parse(McpToolGuideTools.GetToolGuide(HostCatalog(), null, null));
diff --git a/Lite.Tests/McpToolsListBudget/McpDiscoveryTools.txt b/Lite.Tests/McpToolsListBudget/McpDiscoveryTools.txt
index 54f866371..00590a8f6 100644
--- a/Lite.Tests/McpToolsListBudget/McpDiscoveryTools.txt
+++ b/Lite.Tests/McpToolsListBudget/McpDiscoveryTools.txt
@@ -1,2 +1,2 @@
# McpDiscoveryTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks.
-tool list_servers 160
+tool list_servers 382
diff --git a/Lite.Tests/McpToolsListBudget/McpIoTools.txt b/Lite.Tests/McpToolsListBudget/McpIoTools.txt
index a8920b9e2..6997d7ebe 100644
--- a/Lite.Tests/McpToolsListBudget/McpIoTools.txt
+++ b/Lite.Tests/McpToolsListBudget/McpIoTools.txt
@@ -1,5 +1,5 @@
# McpIoTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks.
-tool get_file_io_stats 690
+tool get_file_io_stats 437
param get_file_io_stats.server_name 28
tool get_file_io_trend 585
diff --git a/Lite.Tests/McpToolsListBudget/McpPerfmonTools.txt b/Lite.Tests/McpToolsListBudget/McpPerfmonTools.txt
index 51c832c58..8adfb9cfc 100644
--- a/Lite.Tests/McpToolsListBudget/McpPerfmonTools.txt
+++ b/Lite.Tests/McpToolsListBudget/McpPerfmonTools.txt
@@ -1,5 +1,5 @@
# McpPerfmonTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks.
-tool get_perfmon_stats 1034
+tool get_perfmon_stats 521
param get_perfmon_stats.counter_name 61
param get_perfmon_stats.instance_name 57
param get_perfmon_stats.server_name 28
diff --git a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt
index 4d0ac4712..2158749e6 100644
--- a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt
+++ b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt
@@ -46,18 +46,18 @@ param get_query_trend.hours_back 29
param get_query_trend.query_hash 72
param get_query_trend.server_name 28
-tool get_top_procedures_by_cpu 821
+tool get_top_procedures_by_cpu 406
param get_top_procedures_by_cpu.as_of 167
param get_top_procedures_by_cpu.database_name 30
param get_top_procedures_by_cpu.hours_back 29
param get_top_procedures_by_cpu.server_name 28
param get_top_procedures_by_cpu.top 37
-tool get_top_queries_by_cpu 1606
+tool get_top_queries_by_cpu 474
param get_top_queries_by_cpu.as_of 167
param get_top_queries_by_cpu.database_name 30
param get_top_queries_by_cpu.hours_back 29
param get_top_queries_by_cpu.min_dop 132
-param get_top_queries_by_cpu.parallel_only 371
+param get_top_queries_by_cpu.parallel_only 160
param get_top_queries_by_cpu.server_name 28
param get_top_queries_by_cpu.top 34
diff --git a/Lite.Tests/McpToolsListBudget/McpServerInfoTools.txt b/Lite.Tests/McpToolsListBudget/McpServerInfoTools.txt
index f13b3229c..c32fdb268 100644
--- a/Lite.Tests/McpToolsListBudget/McpServerInfoTools.txt
+++ b/Lite.Tests/McpToolsListBudget/McpServerInfoTools.txt
@@ -2,5 +2,5 @@
tool get_database_sizes 480
param get_database_sizes.server_name 28
-tool get_server_properties 894
+tool get_server_properties 571
param get_server_properties.server_name 28
diff --git a/Lite.Tests/McpToolsListBudget/McpWaitTools.txt b/Lite.Tests/McpToolsListBudget/McpWaitTools.txt
index 7750117d8..3fbb238a2 100644
--- a/Lite.Tests/McpToolsListBudget/McpWaitTools.txt
+++ b/Lite.Tests/McpToolsListBudget/McpWaitTools.txt
@@ -1,5 +1,5 @@
# McpWaitTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks.
-tool get_wait_stats 644
+tool get_wait_stats 316
param get_wait_stats.as_of 167
param get_wait_stats.hours_back 40
param get_wait_stats.limit 145
diff --git a/Lite/Mcp/McpDiscoveryTools.cs b/Lite/Mcp/McpDiscoveryTools.cs
index a56e8e008..a32350814 100644
--- a/Lite/Mcp/McpDiscoveryTools.cs
+++ b/Lite/Mcp/McpDiscoveryTools.cs
@@ -8,7 +8,7 @@ namespace PerformanceMonitorLite.Mcp;
[McpServerToolType]
public sealed class McpDiscoveryTools
{
- [McpServerTool(Name = "list_servers"), Description("Lists all monitored SQL Server instances with their current status and last collection time. Use this first to see available servers before calling other tools.")]
+ [McpServerTool(Name = "list_servers"), Description("Lists monitored servers with collection freshness status and last collection time. Darling has no live connection to monitored servers: status is derived from how recently each was collected (Online = fresh, Warning = stale, Offline = no recent collection). Lite's status IS a live connection check, independent of whether anything is being collected. <> Use this first to see available servers before calling other tools. Lists all monitored SQL Server instances with their current status and last collection time.")]
public static async Task ListServers(ServerManager serverManager, LocalDataService dataService)
{
var servers = serverManager.GetEnabledServers();
diff --git a/Lite/Mcp/McpIoTools.cs b/Lite/Mcp/McpIoTools.cs
index d972d6bb7..ef44c9da8 100644
--- a/Lite/Mcp/McpIoTools.cs
+++ b/Lite/Mcp/McpIoTools.cs
@@ -10,7 +10,7 @@ namespace PerformanceMonitorLite.Mcp;
[McpServerToolType]
public sealed class McpIoTools
{
- [McpServerTool(Name = "get_file_io_stats"), Description("Gets the latest file I/O statistics per database file: read/write counts, bytes, stall times, and calculated latency. High read latency (>20ms) or write latency (>10ms for data, >2ms for log) often indicates storage bottlenecks. Each row carries sample_interval_seconds, the measured seconds its deltas accrued over; a 0 means no delta was knowable for that file at this collection (first sighting, counter reset, or a gap past the delta policy — typically a restart) and its latencies are null rather than 0. LATEST IS A TIME: this reads the newest file-I/O snapshot, not a window, and captured_at is the instant it was collected; the deltas cover the sample_interval_seconds ending there.")]
+ [McpServerTool(Name = "get_file_io_stats"), Description("Gets the latest per-database-file I/O stats: read/write counts, bytes, stall times, calculated latency. LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and the deltas cover the sample_interval_seconds ending there. sample_interval_seconds 0 means no delta was knowable for that file (first sighting, counter reset, a gap) and that row's latencies are null, not 0. <> Gets the latest file I/O statistics per database file: read/write counts, bytes, stall times, and calculated latency. High read latency (>20ms) or write latency (>10ms for data, >2ms for log) often indicates storage bottlenecks. Each row carries sample_interval_seconds, the measured seconds its deltas accrued over; a 0 means no delta was knowable for that file at this collection (first sighting, counter reset, or a gap past the delta policy — typically a restart) and its latencies are null rather than 0. LATEST IS A TIME: this reads the newest file-I/O snapshot, not a window, and captured_at is the instant it was collected; the deltas cover the sample_interval_seconds ending there.")]
public static async Task GetFileIoStats(
LocalDataService dataService,
ServerManager serverManager,
diff --git a/Lite/Mcp/McpPerfmonTools.cs b/Lite/Mcp/McpPerfmonTools.cs
index 14419ddf4..7d48c52f9 100644
--- a/Lite/Mcp/McpPerfmonTools.cs
+++ b/Lite/Mcp/McpPerfmonTools.cs
@@ -10,7 +10,7 @@ namespace PerformanceMonitorLite.Mcp;
[McpServerToolType]
public sealed class McpPerfmonTools
{
- [McpServerTool(Name = "get_perfmon_stats"), Description("Gets the latest SQL Server performance counter values: batch requests/sec, compilations/sec, deadlocks/sec, and more. Provides throughput context to distinguish a busy server from a sick one. Use counter_name or instance_name to filter results. LATEST IS A TIME: this reads the newest counter snapshot, not a window, and captured_at is the instant it was collected; use get_perfmon_trend for a counter over time. Each row carries counter_kind from the stored cntr_type: 'gauge' means value IS the reading (a level such as Total Server Memory (KB); delta_value is null because a level has no delta), 'rate' means value is a cumulative count and delta_value is its change over the last collection interval (get_perfmon_trend carries the sample_interval_seconds to divide it by for a per-second figure), 'other' means an average/fraction numerator whose delta_value is a per-interval change and not a rate; null counter_kind is a row written before the type was stored — classify it by name (a counter whose name ends in /sec is a rate).")]
+ [McpServerTool(Name = "get_perfmon_stats"), Description("Gets the latest SQL Server performance counter values (batch requests/sec, compilations/sec, deadlocks/sec, and more). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected; use get_perfmon_trend for history. counter_kind: gauge = value IS the reading, delta_value null; rate = value is cumulative, delta_value its per-interval change; other = a non-rate per-interval change; null counter_kind predates the column, classify by name (ends in /sec = rate). <> Gets the latest SQL Server performance counter values: batch requests/sec, compilations/sec, deadlocks/sec, and more. Provides throughput context to distinguish a busy server from a sick one. Use counter_name or instance_name to filter results. LATEST IS A TIME: this reads the newest counter snapshot, not a window, and captured_at is the instant it was collected; use get_perfmon_trend for a counter over time. Each row carries counter_kind from the stored cntr_type: 'gauge' means value IS the reading (a level such as Total Server Memory (KB); delta_value is null because a level has no delta), 'rate' means value is a cumulative count and delta_value is its change over the last collection interval (get_perfmon_trend carries the sample_interval_seconds to divide it by for a per-second figure), 'other' means an average/fraction numerator whose delta_value is a per-interval change and not a rate; null counter_kind is a row written before the type was stored — classify it by name (a counter whose name ends in /sec is a rate).")]
public static async Task GetPerfmonStats(
LocalDataService dataService,
ServerManager serverManager,
diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs
index ad02151c7..e152100ef 100644
--- a/Lite/Mcp/McpQueryTools.cs
+++ b/Lite/Mcp/McpQueryTools.cs
@@ -10,7 +10,7 @@ namespace PerformanceMonitorLite.Mcp;
[McpServerToolType]
public sealed class McpQueryTools
{
- [McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive queries from sys.dm_exec_query_stats (plan cache). Best for: currently cached queries with detailed per-execution stats, DOP, spills, and query_hash for trending. Returns query_hash, query_plan_hash, sql_handle, plan_handle, and host_object (the hosting procedure/function for proc-hosted statements, null for ad-hoc) — groups key on (database, query_hash, host_object), so INSERT...EXEC callers in different procedures report separately with their own text. distinct_texts counts statement texts merged into a group (>1 = ad-hoc literal variants or pre-upgrade history; query_text is one representative, 0 means no stored text for the group). Supports database and parallelism filtering; every filter is applied IN the query before the ranking and the cap, so the page is the top-N of the FILTERED population (filter_applied names the parallelism floor in force, null when none), and an empty page under parallel_only/min_dop is the window's answer rather than a page artefact. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. Also returns cpu_attribution: the returned rows' summed CPU-seconds against the SQL process's measured CPU-seconds for the window (avg cpu_utilization % x core count x window) - attributed_cpu_ratio says how much of the box the ranking explains; when the CPU series or core count is missing, or covers too little of the window, the ratio is omitted rather than invented.")]
+ [McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive cached queries from sys.dm_exec_query_stats, ranked by CPU over a window ending at as_of. Filters (database_name, parallel_only, min_dop) apply before the top-N cap: filter_applied names the floor in force, and an empty page under it is the window's real answer, not a miss. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed; cpu_attribution's ratio is omitted, not invented, when its inputs are missing. <> Gets expensive queries from sys.dm_exec_query_stats (plan cache). Best for: currently cached queries with detailed per-execution stats, DOP, spills, and query_hash for trending. Returns query_hash, query_plan_hash, sql_handle, plan_handle, and host_object (the hosting procedure/function for proc-hosted statements, null for ad-hoc) — groups key on (database, query_hash, host_object), so INSERT...EXEC callers in different procedures report separately with their own text. distinct_texts counts statement texts merged into a group (>1 = ad-hoc literal variants or pre-upgrade history; query_text is one representative, 0 means no stored text for the group). Supports database and parallelism filtering; every filter is applied IN the query before the ranking and the cap, so the page is the top-N of the FILTERED population (filter_applied names the parallelism floor in force, null when none), and an empty page under parallel_only/min_dop is the window's answer rather than a page artefact. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. max_dop comes from sys.dm_exec_query_stats and is a lifetime-max for the plan's time in cache, so a plan compiled before MAXDOP was lowered keeps reporting the old higher value until it is evicted or recompiled; confirm current parallelism with analyze_query_plan, which reads the actual plan. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution)]
public static async Task GetTopQueriesByCpu(
LocalDataService dataService,
ServerManager serverManager,
@@ -18,7 +18,7 @@ public static async Task GetTopQueriesByCpu(
[Description("Hours of history. Default 24.")] int hours_back = 24,
[Description("Number of top queries. Default 20.")] int top = 20,
[Description("Filter to a specific database.")] string? database_name = null,
- [Description("If true, only return queries whose cached plan has EVER run at DOP > 1. Note: max_dop comes from sys.dm_exec_query_stats and is a lifetime-max for the plan's time in cache, so a plan compiled before MAXDOP was lowered keeps reporting the old higher value until it is evicted or recompiled. Confirm current parallelism with analyze_query_plan, which reads the actual plan.")] bool parallel_only = false,
+ [Description("If true, only return queries whose cached plan has EVER run at DOP > 1 (a LIFETIME max_dop; can read stale after a MAXDOP change). See the tool's reading guide.")] bool parallel_only = false,
[Description("Minimum DOP to filter on. Implies parallel filtering. Filters the same lifetime-max value as parallel_only, not current parallelism.")] int min_dop = 0,
[Description(McpHelpers.AsOfDescription)] string? as_of = null)
{
@@ -146,7 +146,7 @@ ratio is omitted rather than invented when a denominator piece is missing. One n
}
}
- [McpServerTool(Name = "get_top_procedures_by_cpu"), Description("Gets the most expensive stored procedures ranked by total CPU time. Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. Also returns cpu_attribution: the returned rows' summed CPU-seconds against the SQL process's measured CPU-seconds for the window (avg cpu_utilization % x core count x window) - attributed_cpu_ratio says how much of the box the ranking explains; when the CPU series or core count is missing, or covers too little of the window, the ratio is omitted rather than invented.")]
+ [McpServerTool(Name = "get_top_procedures_by_cpu"), Description("Gets the most expensive stored procedures ranked by total CPU time over a window ending at as_of. Delta-based: requires ~30 minutes after adding a new server before data appears. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed (extremes_note flags a provably stale one); cpu_attribution's ratio is omitted, not invented, when its inputs are missing. <> Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution)]
public static async Task GetTopProceduresByCpu(
LocalDataService dataService,
ServerManager serverManager,
diff --git a/Lite/Mcp/McpServerInfoTools.cs b/Lite/Mcp/McpServerInfoTools.cs
index ca6617892..6108e750d 100644
--- a/Lite/Mcp/McpServerInfoTools.cs
+++ b/Lite/Mcp/McpServerInfoTools.cs
@@ -9,7 +9,7 @@ namespace PerformanceMonitorLite.Mcp;
[McpServerToolType]
public sealed class McpServerInfoTools
{
- [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is.")]
+ [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, memory, socket/core topology, HADR, clustering, and the clock (utc_offset_minutes, time_zone_id). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and on a stalled collector it is the only sign of staleness. time_zone_id is CURRENT_TIMEZONE_ID() (SQL Server 2022+/Azure SQL only); null means a pre-2022 engine, so only the offset in force at captured_at is known, and an instant across a DST transition from it can read an hour off. <> Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is.")]
public static async Task GetServerProperties(
LocalDataService dataService,
ServerManager serverManager,
diff --git a/Lite/Mcp/McpWaitTools.cs b/Lite/Mcp/McpWaitTools.cs
index 704665d63..b4e521070 100644
--- a/Lite/Mcp/McpWaitTools.cs
+++ b/Lite/Mcp/McpWaitTools.cs
@@ -10,7 +10,7 @@ namespace PerformanceMonitorLite.Mcp;
[McpServerToolType]
public sealed class McpWaitTools
{
- [McpServerTool(Name = "get_wait_stats"), Description("Gets the top SQL Server wait types aggregated over a time period, heaviest total wait first. Wait stats reveal what SQL Server spends time waiting on — high signal waits indicate CPU pressure, high resource waits indicate I/O or lock contention. Use this first to identify the dominant wait category, then drill into specific tools based on the wait type. THE PAGE IS BOUNDED BY limit: wait_types_returned is how many wait types you got and truncated says the window observed more than limit — the rows you have are the heaviest, and the ones past the cap are lighter, but a sum over the page is a sum over the page rather than over the server.")]
+ [McpServerTool(Name = "get_wait_stats"), Description("Gets the top SQL Server wait types over a time period ending at as_of, heaviest total wait first. Bounded by limit: wait_types_returned is how many you got; truncated means the window held more. The page holds only the heaviest waits — a sum over the page is not a sum over the server. <> Gets the top SQL Server wait types aggregated over a time period, heaviest total wait first. Wait stats reveal what SQL Server spends time waiting on — high signal waits indicate CPU pressure, high resource waits indicate I/O or lock contention. Use this first to identify the dominant wait category, then drill into specific tools based on the wait type. THE PAGE IS BOUNDED BY limit: wait_types_returned is how many wait types you got and truncated says the window observed more than limit — the rows you have are the heaviest, and the ones past the cap are lighter, but a sum over the page is a sum over the page rather than over the server.")]
public static async Task GetWaitStats(
LocalDataService dataService,
ServerManager serverManager,
diff --git a/PerformanceMonitor.Common/Mcp/McpToolGuideTopics.Data.cs b/PerformanceMonitor.Common/Mcp/McpToolGuideTopics.Data.cs
index df4831d73..60600d077 100644
--- a/PerformanceMonitor.Common/Mcp/McpToolGuideTopics.Data.cs
+++ b/PerformanceMonitor.Common/Mcp/McpToolGuideTopics.Data.cs
@@ -8,9 +8,31 @@
namespace PerformanceMonitor.Common;
-/// The data family's topics (#3898). Empty until the data lane converts its tools and fills this in;
-/// see 's class doc comment for the partial-file pattern.
+/// The data family's topics (#3898).
public static partial class McpToolGuideTopics
{
- private static readonly McpToolGuideTopic[] s_data = [];
+ /// The topic's name.
+ public const string CpuTimeExtremesAndAttributionName = "cpu_time_extremes_and_attribution";
+
+ ///
+ /// get_top_queries_by_cpu and get_top_procedures_by_cpu carried this identical sentence: min/max_cpu_ms
+ /// and min/max_elapsed_ms are lifetime extremes rather than windowed, and cpu_attribution's ratio is
+ /// omitted, never invented, when its inputs are missing.
+ ///
+ public const string CpuTimeExtremesAndAttribution =
+ "min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same " +
+ "semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme " +
+ "provably predates the window carry extremes_note. Also returns cpu_attribution: the returned rows' " +
+ "summed CPU-seconds against the SQL process's measured CPU-seconds for the window (avg cpu_utilization " +
+ "% x core count x window) - attributed_cpu_ratio says how much of the box the ranking explains; when " +
+ "the CPU series or core count is missing, or covers too little of the window, the ratio is omitted " +
+ "rather than invented.";
+
+ private static readonly McpToolGuideTopic[] s_data =
+ [
+ new(
+ CpuTimeExtremesAndAttributionName,
+ "How min/max CPU/elapsed ms and cpu_attribution's ratio read on the top_queries/top_procedures CPU rankings.",
+ CpuTimeExtremesAndAttribution),
+ ];
}