Skip to content
Merged
11 changes: 11 additions & 0 deletions Darling/Darling.Tests/DarlingComposeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,17 @@ public void Catalog_CarriesTheComposeSection()
Assert.NotNull(compose["aggregates"]);
Assert.NotNull(compose["timeBuckets"]);
Assert.NotNull(compose["filterOps"]);

/* #4198: describe_custom_view_catalog's MCP default is now COMPACT (measures grouped by source, most
fields dropped) to fit the tool's 32 KB response budget, but /api/catalog routes here — straight to
BuildComposeCatalogNode(), never through the MCP tool method or its new compact/source-filter logic —
so the web Custom Views editor must still see every measure at full per-field detail, unfiltered and
ungrouped. A regression that wired /api/catalog through the compact builder would drop these fields. */
var firstMeasure = Assert.IsType<JsonObject>(measures[0]);
Assert.NotNull(firstMeasure["appliesTo"]);
Assert.NotNull(firstMeasure["allowedDimensions"]);
Assert.NotNull(firstMeasure["category"]);
Assert.Null(compose["compact"]);
}

/* ─────────────────────────── DoS backstop + loopback scrub (provisioning) ─────────────────────────── */
Expand Down
89 changes: 89 additions & 0 deletions Darling/Darling.Tests/DarlingMcpCustomViewCatalogSizeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* 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.
*/

// Size tests for describe_custom_view_catalog's compact and full-detail modes.
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using PerformanceMonitor.Common;
using PerformanceMonitor.Darling.Service.Mcp;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// #4198 field-size coverage for <c>describe_custom_view_catalog</c>. This tool needs no rig: it is STATIC
/// reference data straight off <c>MeasureCatalog</c> (no server, time window, or store read), so its byte size
/// does not depend on seeded rows the way a row-shaped read tool's does — it depends on the catalog's own field
/// count, which is why it is NOT on the generic <c>McpReadToolBudgetLiveTests</c> roster (#4224, not yet merged;
/// that tool has no server/store argument to seed a payload from) and instead gets this dedicated file.
///
/// <para><b>Before #4198:</b> the full catalog measured 98,173 UTF-8 bytes at default arguments — matching the
/// #4198 issue's own measurement exactly — three times the tool's 32 KB <see cref="McpResponseBudget.DefaultBytes"/>
/// budget. <b>After:</b> the default call groups measures by source and keeps only key/displayName/kind/
/// unitFamily/validAggregates per measure (measured below, well under budget); <c>source=&lt;name&gt;</c> and
/// <c>full_detail=true</c> reach everything the compact default leaves out, and <c>full_detail=true</c> alone
/// reproduces the original 98,173-byte shape byte-for-byte.</para>
/// </summary>
public sealed class DarlingMcpCustomViewCatalogSizeTests
{
/// <summary>The #4198 issue's own measurement of this tool's pre-fix default-argument size (SQL Server store
/// AND PostgreSQL-target store both measured 98,173 B) — this catalog is static, so both stores read the
/// same code path and land on the same number. Pinned so a future catalog edit that silently balloons
/// full_detail's shape is caught here, not by a caller getting refused inline again.</summary>
private const int PreFixFullDetailBytes = 98_173;

[Fact]
public async Task FullDetail_MatchesThePreFixMeasurement()
{
var result = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog(full_detail: true);
var bytes = Encoding.UTF8.GetByteCount(result);

/* Exact match, not a ceiling: full_detail is the #4198 escape hatch and must keep serving EXACTLY what
this tool always returned, so a drift here is either a real catalog change (update the constant, with
the reason) or a regression in the compaction logic leaking into the full-detail path. */
Assert.Equal(PreFixFullDetailBytes, bytes);
}

[Fact]
public async Task Default_IsUnderBudget()
{
var result = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog();
var bytes = Encoding.UTF8.GetByteCount(result);

Assert.True(bytes < McpResponseBudget.DefaultBytes,
$"describe_custom_view_catalog's default call is {bytes:N0} bytes, over the {McpResponseBudget.DefaultBytes:N0}-byte budget (#4198).");

/* Leave real margin, not just a pass: measured at 30,215 B against a 32,768 B budget (about 2.5 KB /
8% headroom) when this was written, so a compaction that only scraped under the ceiling would be one
new measure away from blowing it again. 2,000 bytes is comfortably inside that measured margin. */
const int MinimumHeadroomBytes = 2_000;
Assert.True(bytes < McpResponseBudget.DefaultBytes - MinimumHeadroomBytes,
$"describe_custom_view_catalog's default call is {bytes:N0} bytes - within {MinimumHeadroomBytes:N0} bytes of the {McpResponseBudget.DefaultBytes:N0}-byte budget, with too little headroom for the catalog to grow.");
}

[Fact]
public async Task SourceDrillDown_StaysUnderBudget_ForTheWidestSource()
{
/* The widest source by measure count is the worst case for a single source= call; even it must clear
the budget on its own (full per-measure detail, not compact). */
var compact = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog();
using var compactDoc = JsonDocument.Parse(compact);
var widestSource = compactDoc.RootElement.GetProperty("sources").EnumerateArray()
.OrderByDescending(s => s.GetProperty("measures").GetArrayLength())
.First()
.GetProperty("source").GetString()!;

var drill = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog(source: widestSource);
var bytes = Encoding.UTF8.GetByteCount(drill);

Assert.True(bytes < McpResponseBudget.DefaultBytes,
$"describe_custom_view_catalog(source: '{widestSource}') is {bytes:N0} bytes, over the {McpResponseBudget.DefaultBytes:N0}-byte budget (#4198).");
}
}
112 changes: 105 additions & 7 deletions Darling/Darling.Tests/DarlingMcpCustomViewToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,28 +141,126 @@ public async Task ValidateCustomView_BadMeasure_ReturnsValidFalse_WithError()
}

[Fact]
public async Task DescribeCustomViewCatalog_ReturnsTheComposeVocabulary()
public async Task DescribeCustomViewCatalog_DefaultIsCompact_GroupedBySource()
{
/* The catalog tool exists so an LLM composes a valid panel WITHOUT reading source or guessing names — it
must surface every vocabulary a panel draws from, plus a known measure with its composable fields. */
must surface every vocabulary a panel draws from, plus a known measure with its composable fields.
#4198: the full catalog is 98 KB, so the DEFAULT call groups measures by source and keeps only the
fields a panel spec actually names (key/displayName/kind/unitFamily/validAggregates); source=/
full_detail= reach the rest. Byte-budget coverage of this default lives in
DarlingMcpCustomViewCatalogSizeTests (#4198 exempt from McpReadToolBudgetLiveTests — no server/store
argument to seed). */
var result = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog();
using var doc = JsonDocument.Parse(result);
var root = doc.RootElement;

foreach (var section in new[] { "measures", "dimensions", "unitFamilies", "aggregates", "timeBuckets", "filterOps", "viz" })
foreach (var section in new[] { "sources", "annotationSources", "unitFamilies", "aggregates", "timeBuckets", "filterOps", "viz" })
{
Assert.True(root.TryGetProperty(section, out _), $"catalog is missing the '{section}' vocabulary");
Assert.True(root.TryGetProperty(section, out _), $"compact catalog is missing the '{section}' vocabulary");
}

/* A known measure is discoverable with the fields a panel binds from it (source + valid aggregates). */
Assert.True(root.GetProperty("compact").GetBoolean());

/* A known measure is discoverable, grouped under its source, with the fields a panel binds from it. */
var waitStats = root.GetProperty("sources").EnumerateArray().Single(s => s.GetProperty("source").GetString() == "wait_stats");
var waitTime = waitStats.GetProperty("measures").EnumerateArray().Single(m => m.GetProperty("key").GetString() == "wait_time_ms");
Assert.Equal("scalar", waitTime.GetProperty("kind").GetString());
Assert.Contains("sum", waitTime.GetProperty("validAggregates").EnumerateArray().Select(a => a.GetString()));

/* The scalar vocabularies the panel's aggregate + viz fields draw from. */
Assert.Contains("sum", root.GetProperty("aggregates").EnumerateArray().Select(a => a.GetString()));
Assert.Contains("bar", root.GetProperty("viz").EnumerateArray().Select(v => v.GetString()));
}

[Fact]
public async Task DescribeCustomViewCatalog_FullDetail_ReturnsTodaysOriginalShape()
{
/* full_detail=true is the #4198 escape hatch: the exact flat shape (and every field) this tool always
returned, byte-for-byte what BuildComposeCatalogNode / the web /api/catalog compose section serve. */
var result = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog(full_detail: true);
using var doc = JsonDocument.Parse(result);
var root = doc.RootElement;

foreach (var section in new[] { "measures", "dimensions", "annotationSources", "universalDimensions", "unitFamilies", "aggregates", "timeBuckets", "filterOps", "viz" })
{
Assert.True(root.TryGetProperty(section, out _), $"full_detail catalog is missing the '{section}' vocabulary");
}

Assert.False(root.TryGetProperty("compact", out _), "full_detail must not carry the compact-mode marker");

var waitTime = root.GetProperty("measures").EnumerateArray()
.Single(m => m.GetProperty("key").GetString() == "wait_time_ms");
Assert.Equal("wait_stats", waitTime.GetProperty("source").GetString());
Assert.Contains("sum", waitTime.GetProperty("validAggregates").EnumerateArray().Select(a => a.GetString()));
Assert.True(waitTime.TryGetProperty("appliesTo", out _), "full_detail must keep appliesTo per measure");
Assert.True(waitTime.TryGetProperty("allowedDimensions", out _), "full_detail must keep allowedDimensions per measure");
}

/* The scalar vocabularies the panel's aggregate + viz fields draw from. */
[Fact]
public async Task DescribeCustomViewCatalog_Source_DrillsIntoOneSource_FullDetail()
{
var result = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog(source: "wait_stats");
using var doc = JsonDocument.Parse(result);
var root = doc.RootElement;

Assert.Equal("wait_stats", root.GetProperty("source").GetString());
var measures = root.GetProperty("measures").EnumerateArray().ToList();
Assert.NotEmpty(measures);
Assert.All(measures, m => Assert.Equal("wait_stats", m.GetProperty("source").GetString()));

var waitTime = measures.Single(m => m.GetProperty("key").GetString() == "wait_time_ms");
Assert.True(waitTime.TryGetProperty("appliesTo", out _), "source drill-down must keep appliesTo per measure");
Assert.True(waitTime.TryGetProperty("allowedDimensions", out _), "source drill-down must keep allowedDimensions per measure");

var dimensions = root.GetProperty("dimensions").EnumerateArray().ToList();
Assert.NotEmpty(dimensions);
Assert.All(dimensions, d => Assert.Equal("wait_stats", d.GetProperty("source").GetString()));

/* The small shared vocabularies still ride along, same as every other mode. */
Assert.Contains("sum", root.GetProperty("aggregates").EnumerateArray().Select(a => a.GetString()));
Assert.Contains("bar", root.GetProperty("viz").EnumerateArray().Select(v => v.GetString()));
}

[Fact]
public async Task DescribeCustomViewCatalog_UnknownSource_ReturnsEmptyWithNote_NotAnError()
{
var result = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog(source: "no_such_source");
using var doc = JsonDocument.Parse(result);
var root = doc.RootElement;

Assert.Empty(root.GetProperty("measures").EnumerateArray());
Assert.Empty(root.GetProperty("dimensions").EnumerateArray());
Assert.True(root.TryGetProperty("note", out var note), "an unmatched source should explain how to find the real names");
Assert.Contains("no_such_source", note.GetString());
}

[Fact]
public async Task DescribeCustomViewCatalog_CompactDefault_IsLosslessAndUnderBudget()
{
/* Every measure key the compact default advertises must be reachable, at full detail, either by drilling
into its source or by full_detail=true — the #4198 rule that a cut must never hide what an author
needs. Every source name the compact default lists must itself be a working source= filter. */
var compactResult = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog();
Assert.True(System.Text.Encoding.UTF8.GetByteCount(compactResult) < McpResponseBudget.DefaultBytes,
$"default describe_custom_view_catalog call is {System.Text.Encoding.UTF8.GetByteCount(compactResult):N0} bytes, over the {McpResponseBudget.DefaultBytes:N0}-byte budget");

using var compactDoc = JsonDocument.Parse(compactResult);
var compactKeys = compactDoc.RootElement.GetProperty("sources").EnumerateArray()
.SelectMany(s => s.GetProperty("measures").EnumerateArray().Select(m => m.GetProperty("key").GetString()!))
.ToHashSet();

var fullResult = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog(full_detail: true);
using var fullDoc = JsonDocument.Parse(fullResult);
var fullKeys = fullDoc.RootElement.GetProperty("measures").EnumerateArray().Select(m => m.GetProperty("key").GetString()!).ToHashSet();

Assert.Equal(fullKeys, compactKeys);

foreach (var sourceElement in compactDoc.RootElement.GetProperty("sources").EnumerateArray())
{
var sourceName = sourceElement.GetProperty("source").GetString()!;
var drillResult = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog(source: sourceName);
using var drillDoc = JsonDocument.Parse(drillResult);
Assert.NotEmpty(drillDoc.RootElement.GetProperty("measures").EnumerateArray());
}
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ param create_custom_view.name 60
tool delete_custom_view 158
param delete_custom_view.view_id 54

tool describe_custom_view_catalog 514
tool describe_custom_view_catalog 582
param describe_custom_view_catalog.full_detail 111
param describe_custom_view_catalog.source 186

tool get_custom_view 271
param get_custom_view.view_id 61
Expand Down
9 changes: 8 additions & 1 deletion Darling/Darling.Tests/McpToolsListBudgetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ so neither counts here. */
/* #4198 (lane TB): +364 bytes for get_deadlock_detail's default-preview note in its served description
and its new full_graph opt-in parameter (deadlock_graph_xml, the wide field, is now a 2000-char
preview by default). */
/* #4198 (lane TC): +472 bytes for describe_custom_view_catalog's default-is-compact note in its served
description and its two new opt-ins, source and full_detail (the catalog, 98,173 bytes at default
arguments, is now grouped-by-source with most per-measure fields dropped by default; source drills
into one source's full detail, full_detail returns the original shape). */
/* #4198 (lane TK): +240 bytes for get_collection_health's new full_detail opt-in parameter (the head is
unchanged; the compaction rule lives in the tail get_tool_guide serves, not the served head). Default
calls now compact HEALTHY collectors with nothing to report, which took the default response from
Expand Down Expand Up @@ -146,7 +150,10 @@ combined total with blocking (#4267) changes on top. */
combined total with analysis-findings (#4266) changes on top. */
/* #4198 (collection-health, merge): re-measured after merging origin/dev (dev now includes #4261+#4258+#4265+#4267+#4264+#4266);
combined total with collection-health (#4268) changes on top. */
private const int TotalCeilingBytes = 173_763;
/* #4198 (custom-view-catalog, merge): re-measured after merging origin/dev (dev includes #4261+#4258+#4265+#4267+#4264+#4266+#4268);
combined total with custom-view-catalog (#4272) changes on top. */
private const int TotalCeilingBytes = 174_236;


private const int ConvertedHeadCap = 1_000;
private const int ConvertedParameterCap = 200;
Expand Down
Loading
Loading