From 1bcc0a0c2431c080503b4d15e394a14962a22595 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:48:16 -0400 Subject: [PATCH 1/5] MCP budget: describe_custom_view_catalog default groups measures by source (#4198) Default default-argument call was 98,173 bytes (#4198's own measurement), three times the tool's 32 KB budget. It is pure static reference data (no server/store read), so the cut groups the 179 measures by source and keeps only key/displayName/kind/unitFamily/validAggregates per measure; source= drills into one source's full detail, full_detail=true returns the original shape unfiltered. /api/catalog (the web Custom Views editor) calls the underlying builder directly, never this MCP method, so it is unaffected - pinned in DarlingComposeTests. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- Darling/Darling.Tests/DarlingComposeTests.cs | 11 ++ .../DarlingMcpCustomViewCatalogSizeTests.cs | 88 +++++++++++++ .../DarlingMcpCustomViewToolsTests.cs | 112 +++++++++++++++- .../DarlingMcpCustomViewTools.txt | 4 +- .../Darling.Tests/McpToolsListBudgetTests.cs | 6 +- .../DarlingWebEndpoints.cs | 120 ++++++++++++++++++ .../Mcp/DarlingMcpCustomViewTools.cs | 38 +++++- 7 files changed, 364 insertions(+), 15 deletions(-) create mode 100644 Darling/Darling.Tests/DarlingMcpCustomViewCatalogSizeTests.cs diff --git a/Darling/Darling.Tests/DarlingComposeTests.cs b/Darling/Darling.Tests/DarlingComposeTests.cs index 27ce1b54a..b77268768 100644 --- a/Darling/Darling.Tests/DarlingComposeTests.cs +++ b/Darling/Darling.Tests/DarlingComposeTests.cs @@ -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(measures[0]); + Assert.NotNull(firstMeasure["appliesTo"]); + Assert.NotNull(firstMeasure["allowedDimensions"]); + Assert.NotNull(firstMeasure["category"]); + Assert.Null(compose["compact"]); } /* ─────────────────────────── DoS backstop + loopback scrub (provisioning) ─────────────────────────── */ diff --git a/Darling/Darling.Tests/DarlingMcpCustomViewCatalogSizeTests.cs b/Darling/Darling.Tests/DarlingMcpCustomViewCatalogSizeTests.cs new file mode 100644 index 000000000..5616db2cb --- /dev/null +++ b/Darling/Darling.Tests/DarlingMcpCustomViewCatalogSizeTests.cs @@ -0,0 +1,88 @@ +/* + * 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.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; + +/// +/// #4198 field-size coverage for describe_custom_view_catalog. This tool needs no rig: it is STATIC +/// reference data straight off MeasureCatalog (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 McpReadToolBudgetLiveTests roster (#4224, not yet merged; +/// that tool has no server/store argument to seed a payload from) and instead gets this dedicated file. +/// +/// Before #4198: 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 +/// budget. After: the default call groups measures by source and keeps only key/displayName/kind/ +/// unitFamily/validAggregates per measure (measured below, well under budget); source=<name> and +/// full_detail=true reach everything the compact default leaves out, and full_detail=true alone +/// reproduces the original 98,173-byte shape byte-for-byte. +/// +public sealed class DarlingMcpCustomViewCatalogSizeTests +{ + /// 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. + 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)."); + } +} diff --git a/Darling/Darling.Tests/DarlingMcpCustomViewToolsTests.cs b/Darling/Darling.Tests/DarlingMcpCustomViewToolsTests.cs index f58a8271e..59552c81e 100644 --- a/Darling/Darling.Tests/DarlingMcpCustomViewToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpCustomViewToolsTests.cs @@ -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] diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpCustomViewTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpCustomViewTools.txt index b742ab7df..d483ec7f3 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpCustomViewTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpCustomViewTools.txt @@ -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 diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs index 47a154984..50172f70b 100644 --- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs +++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs @@ -99,7 +99,11 @@ 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). */ - private const int TotalCeilingBytes = 172_220; + /* #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). */ + private const int TotalCeilingBytes = 172_692; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs index 5a9116b55..0154ece84 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs @@ -2216,6 +2216,126 @@ dimensions list — surfaced here so the composer offers it on every measure. */ }; } + /// + /// The MCP describe_custom_view_catalog tool's DEFAULT shape (#4198 — the full catalog measured 98,173 + /// bytes at default arguments, three times the tool's 32 KB response budget). Re-groups + /// 's flat measures by source (collector table) and keeps + /// only the fields an author needs to pick a measure and know its legal vocabulary: key, + /// displayName (the one-line purpose), kind, unitFamily, validAggregates (this one + /// VARIES within a source — e.g. a ratio measure's is empty where its source's scalars allow sum/avg/min/max — + /// so, unlike allowedDimensions, it cannot be hoisted to the source level without hiding a real + /// restriction). Left out: category/archetype (neither is part of the vocabulary a panel spec + /// names — 's required panel fields are measure/aggregate/unit, never these), + /// nativeUnit/defaultUnit/defaultAggregate (suggestions, not requirements — any unit in + /// the measure's family and any aggregate in its validAggregates is legal), allowedDimensions (uniform + /// across every measure of a source — pinned by DarlingComposeTests — so it is the source's dimension + /// list, one call away), and appliesTo (server-type + /// availability is a UI greying hint for the web composer, per design D4 — it gates nothing at compose time). + /// Every dropped field is reachable per-source via source=<name> + /// () or for the whole catalog via full_detail=true + /// (this method's own return, unfiltered). + /// + /// Takes the ALREADY-BUILT full node so the compact view is a pure re-shape of it, never a second read + /// of — it can't drift from what full_detail=true and /api/catalog + /// (which never compacts; the web Custom Views editor needs the fields this trims) both serve. + /// + internal static JsonObject BuildComposeCatalogCompactNode(JsonObject full) + { + var sources = new JsonArray(); + foreach (var sourceGroup in full["measures"]!.AsArray() + .Select(m => m!.AsObject()) + .GroupBy(m => m["source"]!.GetValue(), StringComparer.Ordinal)) + { + var measures = new JsonArray(); + foreach (var m in sourceGroup) + { + measures.Add(new JsonObject + { + ["key"] = m["key"]!.GetValue(), + ["displayName"] = m["displayName"]!.GetValue(), + ["kind"] = m["kind"]!.GetValue(), + ["unitFamily"] = m["unitFamily"]!.GetValue(), + ["validAggregates"] = JsonNode.Parse(m["validAggregates"]!.ToJsonString()), + }); + } + + sources.Add(new JsonObject { ["source"] = sourceGroup.Key, ["measures"] = measures }); + } + + var annotationSources = new JsonArray(); + foreach (var a in full["annotationSources"]!.AsArray()) + { + var ao = a!.AsObject(); + annotationSources.Add(new JsonObject + { + ["key"] = ao["key"]!.GetValue(), + ["displayName"] = ao["displayName"]!.GetValue(), + ["category"] = ao["category"]!.GetValue(), + }); + } + + return new JsonObject + { + ["sources"] = sources, + ["annotationSources"] = annotationSources, + ["universalDimensions"] = JsonNode.Parse(full["universalDimensions"]!.ToJsonString()), + ["unitFamilies"] = JsonNode.Parse(full["unitFamilies"]!.ToJsonString()), + ["aggregates"] = JsonNode.Parse(full["aggregates"]!.ToJsonString()), + ["timeBuckets"] = JsonNode.Parse(full["timeBuckets"]!.ToJsonString()), + ["filterOps"] = JsonNode.Parse(full["filterOps"]!.ToJsonString()), + ["viz"] = JsonNode.Parse(full["viz"]!.ToJsonString()), + ["compact"] = true, + ["note"] = "Compact by default (#4198): each source lists its measures' key/displayName/kind/unitFamily/validAggregates " + + "only. Call describe_custom_view_catalog(source=\"\") for that source's FULL per-measure detail " + + "(category, archetype, nativeUnit, defaultUnit, defaultAggregate, allowedDimensions, appliesTo) plus its own " + + "dimensions, or full_detail=true for the complete catalog (measures/dimensions/annotationSources as flat arrays, " + + "every field, exactly like this tool returned before #4198).", + }; + } + + /// + /// The MCP describe_custom_view_catalog tool's source=<name> drill-down (#4198): the FULL + /// per-entry detail serves, filtered to one source (collector table)'s + /// measures and dimensions. annotationSources rides along whole either way — five entries total, cheap + /// regardless of the filter, and its underlying source table is not itself a served field to filter on. An + /// unmatched comes back with empty measures/dimensions and a + /// note pointing at the default (unfiltered) call for the real source names, rather than an error + /// envelope — this tool has never returned one, and a typo should not change the shape the caller parses. + /// + internal static JsonObject FilterComposeCatalogNodeBySource(JsonObject full, string source) + { + var measures = new JsonArray(full["measures"]!.AsArray() + .Where(m => string.Equals(m!["source"]!.GetValue(), source, StringComparison.Ordinal)) + .Select(m => JsonNode.Parse(m!.ToJsonString())!) + .ToArray()); + + var dimensions = new JsonArray(full["dimensions"]!.AsArray() + .Where(d => string.Equals(d!["source"]!.GetValue(), source, StringComparison.Ordinal)) + .Select(d => JsonNode.Parse(d!.ToJsonString())!) + .ToArray()); + + var result = new JsonObject + { + ["source"] = source, + ["measures"] = measures, + ["dimensions"] = dimensions, + ["annotationSources"] = JsonNode.Parse(full["annotationSources"]!.ToJsonString()), + ["universalDimensions"] = JsonNode.Parse(full["universalDimensions"]!.ToJsonString()), + ["unitFamilies"] = JsonNode.Parse(full["unitFamilies"]!.ToJsonString()), + ["aggregates"] = JsonNode.Parse(full["aggregates"]!.ToJsonString()), + ["timeBuckets"] = JsonNode.Parse(full["timeBuckets"]!.ToJsonString()), + ["filterOps"] = JsonNode.Parse(full["filterOps"]!.ToJsonString()), + ["viz"] = JsonNode.Parse(full["viz"]!.ToJsonString()), + }; + + if (measures.Count == 0) + { + result["note"] = $"No measures found for source '{source}'. Call describe_custom_view_catalog() with no arguments to list the valid source names."; + } + + return result; + } + /// Collector definition by its destination table, for the per-measure availability lookup (a /// measure's SourceTable is a collector TargetTable, pinned by DarlingComposeTests). private static readonly Dictionary s_collectorByTable = diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs index 56e591361..a4da4aa53 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs @@ -294,7 +294,8 @@ public static async Task RunCustomViewPanel( [McpServerTool(Name = "describe_custom_view_catalog"), Description( "Returns the COMPOSE CATALOG: the exact vocabulary (measures, dimensions, aggregates, units, timeBuckets, viz) " + "a composed (v2) panel may use; the compiler emits ONLY these identifiers. CALL THIS FIRST before " + - "create/update/validate/run_custom_view_panel. On query_stats, ad-hoc SQL carries the literal '(ad hoc)' " + + "create/update/validate/run_custom_view_panel. Default is COMPACT (#4198, see guide); source/full_detail " + + "get more. On query_stats, ad-hoc SQL carries the literal '(ad hoc)' " + "module; a neq filter on procedure name still INCLUDES those rows (the dimension value is never null). " + "Static reference data: no server, time window, or collected-data read. <> " + "Returns the COMPOSE CATALOG — the exact vocabulary a composed (v2) custom-view panel may draw from — so you " + @@ -302,11 +303,27 @@ public static async Task RunCustomViewPanel( "update_custom_view / validate_custom_view / run_custom_view_panel: the panel's 'source', 'measure'/'ratio', " + "'aggregate', 'unit', 'groupBy'/'filters' dimensions, 'timeBucket', and 'viz' must all come from this " + "catalog (the compiler emits ONLY these identifiers), and the validation errors do not enumerate the legal " + - "names, so guessing them is slow. Returns {measures, dimensions, annotationSources, universalDimensions, " + - "unitFamilies, aggregates, timeBuckets, filterOps, viz}. Each measure names its 'source' (collector table), " + + "names, so guessing them is slow. " + + "DEFAULT CALL (#4198 — the full catalog is 98 KB, three times this tool's budget): {sources: [{source, " + + "measures: [{key, displayName, kind, unitFamily, validAggregates}]}], annotationSources (key/displayName/" + + "category only), universalDimensions, unitFamilies, aggregates, timeBuckets, filterOps, viz, compact: true, " + + "note}. displayName is the measure's one-line purpose; kind is scalar|ratio (use the key as the panel's " + + "'measure', or as 'ratio' when kind='ratio'); validAggregates is what the panel's 'aggregate' may be — it " + + "is the one field that VARIES within a source (a ratio measure's list differs from its source's scalars), " + + "so it stays inline rather than move to the source level like allowedDimensions did. " + + "SOURCE DRILL-DOWN (source=\"wait_stats\", a name from the default call's sources[].source): {source, " + + "measures (every field below, filtered to this source), dimensions (this source's filterable/groupable " + + "columns), annotationSources, ...the same small vocabularies}. An unmatched source comes back with empty " + + "measures/dimensions and a note, not an error. " + + "FULL_DETAIL=true (or full_detail with source: same, unfiltered): today's original shape, {measures, " + + "dimensions, annotationSources, universalDimensions, unitFamilies, aggregates, timeBuckets, filterOps, " + + "viz}, every measure/dimension/annotationSource at every field — the same shape web /api/catalog serves " + + "the Custom Views editor (unrelated to this default; the editor always gets the full catalog). " + + "Each measure names its 'source' (collector table), " + "its 'key' (use as the panel's 'measure', or as 'ratio' when kind='ratio'), its 'kind' (scalar|ratio), the " + "'validAggregates' and 'allowedDimensions' legal for it, its unit family + default/native unit, and " + - "'appliesTo' (which server types — onPrem/azureSqlDb/azureMi/awsRds — can collect it). A panel then names a " + + "'appliesTo' (which server types — onPrem/azureSqlDb/azureMi/awsRds — can collect it; a UI greying hint, " + + "not a compose-time restriction). A panel then names a " + "'source' + 'measure'|'ratio', an 'aggregate' from that measure's validAggregates, a 'unit' from its family, " + "an optional 'timeBucket' (time series; prefer 'auto', which adapts the grain minute/hour/day to the " + "panel's window so any range renders), 'topN' (ranked), or BOTH (the bucketed trend of exactly the top-N " + @@ -324,11 +341,20 @@ public static async Task RunCustomViewPanel( "null). For a true top-statements panel group by 'statement' instead: procedures keep their module name " + "and ad-hoc statements stay distinct by query_hash. Static " + "reference data — no server or time window needed; it reads no monitored server and no collected data.")] - public static Task DescribeCustomViewCatalog() + public static Task DescribeCustomViewCatalog( + [Description("Drill into ONE source (collector table, e.g. 'wait_stats') for its full per-measure detail plus its " + + "dimensions. A name from the default call's sources[].source. Default null (no filter).")] string? source = null, + [Description("Return the complete catalog at full per-entry detail, ungrouped, instead of the compact default. Default false.")] bool full_detail = false) { try { - return Task.FromResult(DarlingWebEndpoints.BuildComposeCatalogNode().ToJsonString(McpHelpers.JsonOptions)); + var full = DarlingWebEndpoints.BuildComposeCatalogNode(); + var result = source is not null + ? DarlingWebEndpoints.FilterComposeCatalogNodeBySource(full, source) + : full_detail + ? full + : DarlingWebEndpoints.BuildComposeCatalogCompactNode(full); + return Task.FromResult(result.ToJsonString(McpHelpers.JsonOptions)); } catch (Exception ex) { From 9ed8513fb664d6652be55fa331899074b51ddf52 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:46:53 -0400 Subject: [PATCH 2/5] Trigger CI (draft PR skipped the Build workflow) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ From a85ff046e74f6ecc042369f4a3b0b9747660a21c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:58:31 -0400 Subject: [PATCH 3/5] Add comment to DarlingMcpCustomViewCatalogSizeTests.cs to trigger Build CI GitHub did not fire pull_request events for this draft-opened PR. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ --- Darling/Darling.Tests/DarlingMcpCustomViewCatalogSizeTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Darling/Darling.Tests/DarlingMcpCustomViewCatalogSizeTests.cs b/Darling/Darling.Tests/DarlingMcpCustomViewCatalogSizeTests.cs index 5616db2cb..ca76f7b60 100644 --- a/Darling/Darling.Tests/DarlingMcpCustomViewCatalogSizeTests.cs +++ b/Darling/Darling.Tests/DarlingMcpCustomViewCatalogSizeTests.cs @@ -6,6 +6,7 @@ * 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; From cff2c89a70ecccb183d0d206e5612708600ea32c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:02:44 -0400 Subject: [PATCH 4/5] fix(#4272): reword validAggregates description - compact drops allowedDimensions, source= returns it Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../Mcp/DarlingMcpCustomViewTools.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs index a4da4aa53..d2ab75188 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs @@ -310,7 +310,7 @@ public static async Task RunCustomViewPanel( "note}. displayName is the measure's one-line purpose; kind is scalar|ratio (use the key as the panel's " + "'measure', or as 'ratio' when kind='ratio'); validAggregates is what the panel's 'aggregate' may be — it " + "is the one field that VARIES within a source (a ratio measure's list differs from its source's scalars), " + - "so it stays inline rather than move to the source level like allowedDimensions did. " + + "so it stays inline, because the compact form drops allowedDimensions and dimensions, and `source=` returns them. " + "SOURCE DRILL-DOWN (source=\"wait_stats\", a name from the default call's sources[].source): {source, " + "measures (every field below, filtered to this source), dimensions (this source's filterable/groupable " + "columns), annotationSources, ...the same small vocabularies}. An unmatched source comes back with empty " + From 4b332392cbbf461a26253427886a4e928ecf64f7 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:08:27 -0400 Subject: [PATCH 5/5] #4272: set TotalCeilingBytes to the measured 174,236 after merging dev Measured with McpToolsListBudgetTests after merging origin/dev (#4261, #4258, #4265, #4267, #4264, #4266 and #4268) plus this PR's catalog changes. Budget, census and tool-guide classes: 219/219. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ --- Darling/Darling.Tests/McpToolsListBudgetTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs index c67fcae3f..57c5d9a5a 100644 --- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs +++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs @@ -152,7 +152,7 @@ combined total with blocking (#4267) changes on top. */ combined total with collection-health (#4268) changes on top. */ /* #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 = 999999; + private const int TotalCeilingBytes = 174_236; private const int ConvertedHeadCap = 1_000;