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), + ]; }