Skip to content
Merged
52 changes: 41 additions & 11 deletions Darling/Darling.Tests/McpLatestSnapshotStampTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/// <summary>A Lite <c>const string</c>'s literal, read from source: <c>internal const string Name =\n "…";</c>.</summary>
Expand Down
34 changes: 33 additions & 1 deletion Darling/Darling.Tests/McpPageContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1179,6 +1179,38 @@ private static string ToolBody(string source, string toolName)
return next < 0 ? source[start..] : source[start..next];
}

/// <summary>The tool's Description, from source: the FIRST <c>Description(</c> in a <see cref="ToolBody"/>
/// slice is always the tool's own (a parameter's own <c>[Description("...")]</c> comes later, on the
/// method signature). #3898: a converted tool's Description is a <c>"head" + McpToolGuide.Marker +
/// "tail"</c> concatenation, not one literal, so this joins every quoted chunk up to the attribute's
/// closing <c>)]</c> and skips the identifiers between them (the marker, McpToolGuideTopics.* consts) —
/// this reads the prose only, never the reading-guide plumbing those identifiers resolve to.</summary>
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();
}

/// <summary>A non-tool member's span (LF source): from its <c>static &lt;type&gt; Name(</c> 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
Expand Down
85 changes: 85 additions & 0 deletions Darling/Darling.Tests/McpToolGuideHeads.Data.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// #3898 D3 head pins for the "Data" family: the core resource/query/discovery tools converted out of
/// <c>DarlingMcpDataTools</c> (Darling) and their Lite twins. Follows the pattern in
/// <see cref="McpToolGuideHeadsHealthParserTests"/>.
/// </summary>
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",
];

/// <summary>The per-tool guardrail phrase each head must state.</summary>
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);
}
}

/// <summary>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.</summary>
[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);
}

/// <summary>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).</summary>
[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);
}
}
13 changes: 11 additions & 2 deletions Darling/Darling.Tests/McpToolGuideTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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());
Expand All @@ -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));
Expand Down Expand Up @@ -342,6 +345,12 @@ private static (Dictionary<string, string> Files, Dictionary<string, bool> 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 <<GUIDE>>`) 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.");
}
}

Expand Down
18 changes: 9 additions & 9 deletions Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Loading
Loading