diff --git a/Darling/Darling.Tests/AnalysisFactsReadRunsDetectorParityTests.cs b/Darling/Darling.Tests/AnalysisFactsReadRunsDetectorParityTests.cs index 5064e88cf0..0201ee3ce5 100644 --- a/Darling/Darling.Tests/AnalysisFactsReadRunsDetectorParityTests.cs +++ b/Darling/Darling.Tests/AnalysisFactsReadRunsDetectorParityTests.cs @@ -139,15 +139,16 @@ public void BothToolDescriptions_SayTheDetectorRunsOnThisRead() Assert.Contains("the cost is the detector's baseline reads on top of the collector's", attribute, StringComparison.Ordinal); } - /* And the instructions rows no longer describe the read as collect+score while reserving anomaly - detection for analyze_server. */ + /* #3898 Phase 2 (D5): the instructions' per-tool row that used to restate this is gone on both SKUs — + the "Asking about a PAST window" section's as_of guardrail still says the anchor reaches the + detector, which is the fact this half of the pin protects; the stale collect+score phrasing it + guarded against cannot come back once there is no per-tool row left to carry it. */ foreach (var (sku, instructions) in new[] { ("Darling", RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpInstructions.cs")), ("Lite", RepoFile.ReadRepoFile("Lite", "Mcp", "McpInstructions.cs")), }) { - Assert.Contains("`get_analysis_facts` | Exposes raw scored facts from the collect+detect+score pipeline", instructions, StringComparison.Ordinal); Assert.Contains("re-run fact collection, anomaly detection and scoring over the anchored window", instructions, StringComparison.Ordinal); Assert.DoesNotContain("`analyze_server`'s anomaly detection moves with it", instructions, StringComparison.Ordinal); } diff --git a/Darling/Darling.Tests/CollectionCaveatLedgerTests.cs b/Darling/Darling.Tests/CollectionCaveatLedgerTests.cs index c295d80061..f8655dc981 100644 --- a/Darling/Darling.Tests/CollectionCaveatLedgerTests.cs +++ b/Darling/Darling.Tests/CollectionCaveatLedgerTests.cs @@ -477,32 +477,29 @@ touched one and not the other produced a surface where an agent learned a field Assert.Equal(descriptions[0].Text, descriptions[1].Text); } + /// + /// #3898 Phase 2 (D5): the instructions' per-tool row that used to carry this clause is gone on both + /// SKUs — get_collection_health's own description is now the only surface, and it already states + /// the clause without the mid-sentence splice this pin was written against (the defect where it landed + /// inside "come in two sizes and … the mean describes neither"). + /// [Fact] - public void BothInstructionTables_CarryTheClauseOnTheirCollectionHealthRow() + public void BothDescriptions_CarryTheAnalysisCaveatsClause_WithoutASplice() { foreach (var (sku, path) in new[] { - ("Darling", new[] { "Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpInstructions.cs" }), - ("Lite", new[] { "Lite", "Mcp", "McpInstructions.cs" }) + ("Darling", new[] { "Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs" }), + ("Lite", new[] { "Lite", "Mcp", "McpHealthTools.cs" }) }) { - /* The RAW reader, with each line trimmed: every anchor here lives inside ONE table row, so - nothing this pin matches spans a line break and the LF reader would state a property it does - not have (the RepoFileAdoptionTests distinction). */ var source = RepoFile.ReadRepoFile(path); - var row = source - .Split('\n') - .Select(l => l.Trim()) - .SingleOrDefault(l => l.StartsWith("| `get_collection_health` |", StringComparison.Ordinal)); - Assert.False(row is null, $"{sku}: the get_collection_health instruction row has moved"); - - Assert.Contains("; carries analysis_caveats when a recent analysis pass could not read a fact family |", row!, StringComparison.Ordinal); - /* The clause is APPENDED to the row's prose, not spliced into the middle of a sentence — the - defect this pin was written against, where it landed inside "come in two sizes and … the mean - describes neither". */ - Assert.DoesNotContain("and ; carries analysis_caveats", row!, StringComparison.Ordinal); - Assert.DoesNotContain("fact familyread", row!, StringComparison.Ordinal); - Assert.DoesNotContain("fact familythe", row!, StringComparison.Ordinal); + var at = source.IndexOf("carries analysis_caveats", StringComparison.Ordinal); + Assert.True(at > 0, $"{sku}: get_collection_health's analysis_caveats clause has moved"); + + var clause = source.Substring(at, 200); + Assert.DoesNotContain("mean describes neitheranalysis_caveats", clause, StringComparison.Ordinal); + Assert.DoesNotContain("familyread", clause, StringComparison.Ordinal); + Assert.DoesNotContain("familythe", clause, StringComparison.Ordinal); } } diff --git a/Darling/Darling.Tests/DarlingMcpAgToolsTests.cs b/Darling/Darling.Tests/DarlingMcpAgToolsTests.cs index 71629b769a..da9093c3f9 100644 --- a/Darling/Darling.Tests/DarlingMcpAgToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpAgToolsTests.cs @@ -155,13 +155,13 @@ public void Description_SurfacesTheReadersTwoDocumentedTraps() Assert.Contains("is_suspended", description, StringComparison.Ordinal); Assert.Contains("0 lag while data movement is suspended", description, StringComparison.Ordinal); - /* The instructions' AG paragraph is the other text an agent reads first; it carries the same two traps - in its own words, and the perspective trap (LOCAL-replica-only columns) it always had. */ - var instructions = DarlingMcpInstructions.Text; - Assert.Contains("lag and queue depth are NOT banded", instructions, StringComparison.Ordinal); - Assert.Contains("the DMV reports lag as 0 (not null) while data movement is suspended", instructions, StringComparison.Ordinal); - Assert.Contains("keeps returning its last non-empty snapshot", instructions, StringComparison.Ordinal); - Assert.Contains("populated only for the LOCAL replica", instructions, StringComparison.Ordinal); + /* #3898 Phase 2 (D5): the instructions' per-tool AG paragraph is gone — the tool's own description is + now the ONLY surface, so the perspective trap (LOCAL-replica-only columns) moved onto it here rather + than staying duplicated. All four traps checked in one place. */ + Assert.Contains("lag and queue depth are NOT banded", description, StringComparison.Ordinal); + Assert.Contains("the DMV reports 0 lag while data movement is suspended", description, StringComparison.Ordinal); + Assert.Contains("keeps returning its last non-empty snapshot", description, StringComparison.Ordinal); + Assert.Contains("populated only for the LOCAL replica", description, StringComparison.Ordinal); } } diff --git a/Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs b/Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs index c0d3a91417..6255594a9a 100644 --- a/Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs @@ -151,23 +151,25 @@ public void ParamContract_DedupKeyIsAnAppendedOptionalOnTheIncidentReaders() } /// - /// The instructions have to ADVERTISE dedup_key, or the feature is unreachable in practice: an agent - /// picks tools and arguments from this text, and a parameter it never reads is one it never passes. Same - /// reason the store-metrics and AG tools pin their own mentions. - /// - /// Also pins the two caveats that turn an empty result into a diagnosable one — the display-name - /// scoping and that hours_back still bounds the search — because those are the failure modes an agent - /// would otherwise report as "no such incident". + /// The parameter's OWN description has to ADVERTISE dedup_key's scoping, or the feature is + /// unreachable in practice: an agent picks tools and arguments from a tool's own description, and a + /// caveat it never reads is one it never accounts for. #3898 Phase 2 (D5) moved this pin off the + /// instructions (which used to carry a second, shorter mention) onto the parameter description that was + /// always the primary surface — the display-name scoping is the failure mode an agent would otherwise + /// report as "no such incident". /// - [Fact] - public void Instructions_AdvertiseDedupKeyAndItsScoping() + [Theory] + [InlineData("get_blocking")] + [InlineData("get_deadlocks")] + [InlineData("get_deadlock_detail")] + public void ParamContract_DedupKeyDescription_AdvertisesItsScoping(string tool) { - var text = DarlingMcpInstructions.Text; + var method = ToolMethods().Single(m => m.GetCustomAttribute()!.Name == tool); + var description = method.GetParameters().Single(p => p.Name == "dedup_key") + .GetCustomAttribute()!.Description; - Assert.Contains("dedup_key", text, StringComparison.Ordinal); - Assert.Contains("Dedup Key", text, StringComparison.Ordinal); - Assert.Contains("DISPLAY name", text, StringComparison.Ordinal); - Assert.Contains("hours_back` still bounds the search", text, StringComparison.Ordinal); + Assert.Contains("Dedup Key", description, StringComparison.Ordinal); + Assert.Contains("display name", description, StringComparison.Ordinal); } [Fact] diff --git a/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs b/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs index 365df2b235..b6464d2971 100644 --- a/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs @@ -240,15 +240,16 @@ of the same length would not be visible. Both filters must be findable through i /// /// get_collection_log's parameter list, held identical across every surface that writes it down. /// - /// Five places name these parameters: Darling's signature, Lite's signature, the two quick-reference - /// instruction tables (byte-identical rows on both SKUs), and the /api/catalog descriptor. #3287 - /// added two filters to the first two and review found the other three stale — and nothing caught it, - /// because the new parameters are OPTIONAL and no shipped caller sends them, so every existing pin stayed - /// green. That is the same shape as the defect being fixed: a surface advertising a parameter list that no - /// longer matches what the read accepts, with no way for a consumer to discover the difference. + /// Three places name these parameters: Darling's signature, Lite's signature, and the + /// /api/catalog descriptor — down from five once #3898 Phase 2 (D5) retired the two quick-reference + /// instruction tables that used to carry byte-identical rows on both SKUs. #3287 added two filters to the + /// first two and review found the other three stale — and nothing caught it, because the new parameters + /// are OPTIONAL and no shipped caller sends them, so every existing pin stayed green. That is the same + /// shape as the defect being fixed: a surface advertising a parameter list that no longer matches what the + /// read accepts, with no way for a consumer to discover the difference. /// /// So the list is derived from the signature and compared, rather than each surface being spot-checked - /// for the two names this change happened to add. A third filter added to the tool and not to the tables + /// for the two names this change happened to add. A third filter added to the tool and not to the catalog /// reds this. /// /// The catalog is asserted by CONTAINMENT rather than equality, because it names parameters as they @@ -262,19 +263,6 @@ public void CollectionLogParams_AreTheSameListOnEverySurfaceThatWritesThemDown() Assert.Equal(reflected, LiteMcpParamNames("get_collection_log", "GetCollectionLog")); - /* The tables render the list as backticked names joined by ", " — the shape every other row uses. */ - var expectedCell = string.Join(", ", reflected.Select(n => $"`{n}`")); - - var liteInstructions = File.ReadAllText(Path.Combine(RepoRoot(), "Lite", "Mcp", "McpInstructions.cs")); - foreach (var (surface, text) in new[] - { - ("Darling's instruction table", DarlingMcpInstructions.Text), - ("Lite's instruction table", liteInstructions), - }) - { - Assert.Equal(expectedCell, CollectionLogInstructionCell(surface, text)); - } - var catalog = DarlingWebEndpoints.CatalogDescriptors["get_collection_log"].Params; foreach (var filter in new[] { "collector_name", "min_duration_ms" }) @@ -430,23 +418,6 @@ private static string ToolDescriptionOf(string source, string toolName) return source[at..end]; } - /// The "Key Parameters" cell of the get_collection_log row in a quick-reference table. - private static string CollectionLogInstructionCell(string surface, string text) - { - var row = text - .Split('\n') - .SingleOrDefault(l => l.TrimStart().StartsWith("| `get_collection_log` |", StringComparison.Ordinal)); - - Assert.True(row is not null, $"{surface} has no `get_collection_log` row — this pin needs re-anchoring."); - - /* Four cells between five pipes, so the parameter list is the last populated one. The row is a single - line by convention and its prose carries no pipe, which is what makes this safe. */ - var cells = row!.TrimEnd('\r').Split('|'); - Assert.True(cells.Length >= 4, $"{surface}'s `get_collection_log` row is not a four-cell row."); - - return cells[^2].Trim(); - } - /// The advertised MCP parameter names of one Lite tool, in declaration order, read out of Lite's /// own source — Darling.Tests holds no ProjectReference to Lite, and reading Lite .cs across the seam is /// the pattern #2839 established for exactly this. diff --git a/Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs b/Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs index f31f4463e3..928804cf08 100644 --- a/Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs @@ -109,6 +109,7 @@ public void Description_CarriesTheMuteSemantics_AndTheQuietIsNotCleanBlock() var description = ToolMethods().Single() .GetCustomAttribute()!.Description; + Assert.Contains("MUTE SEMANTICS", description, StringComparison.Ordinal); Assert.Contains("DELIVERY WAS OFF", description, StringComparison.Ordinal); Assert.Contains("would_have_paged", description, StringComparison.Ordinal); Assert.Contains("muted, and nothing would have paged", description, StringComparison.Ordinal); @@ -118,6 +119,9 @@ public void Description_CarriesTheMuteSemantics_AndTheQuietIsNotCleanBlock() Assert.Contains("instrument_liveness", description, StringComparison.Ordinal); /* The knob's home is named, so an agent that wants the cadence goes to the settings read. */ Assert.Contains("fleet_sweep", description, StringComparison.Ordinal); + /* #3898 Phase 2: the instructions' cross-server paragraph carried this cadence fact; once that + paragraph was cut, the description became the only place an agent could learn it. */ + Assert.Contains("at most one daily rollup", description, StringComparison.Ordinal); } /// #3487's output side, stated where an agent reads: every sweep id the payload carries @@ -240,18 +244,9 @@ which is the whole reason the logger rides by closure. */ source, StringComparison.Ordinal); } - /// The house rule every tool suite applies: the instructions an MCP client plans against - /// must carry the tool — and for this one, the same two field contracts the description carries, - /// because the instructions' cross-server section is where an agent first meets the sweep. - [Fact] - public void TheInstructions_CarryTheTool_AndItsFieldContracts() - { - var text = DarlingMcpInstructions.Text; - Assert.Contains("get_sweep_reports", text, StringComparison.Ordinal); - Assert.Contains("MUTE SEMANTICS", text, StringComparison.Ordinal); - Assert.Contains("QUIET IS NOT CLEAN", text, StringComparison.Ordinal); - Assert.Contains("bounded at ONE daily rollup", text, StringComparison.Ordinal); - } + /* #3898 Phase 2 (D5): the instructions' duplicate cross-server paragraph naming this tool and its field + contracts is gone — Description_CarriesTheMuteSemantics_AndTheQuietIsNotCleanBlock above is now the + only pin on them, the tool's own description being the sole surface left to carry them. */ /* ---------------- advertised MCP schema ---------------- */ diff --git a/Darling/Darling.Tests/DarlingMcpInstructionsBudgetTests.cs b/Darling/Darling.Tests/DarlingMcpInstructionsBudgetTests.cs new file mode 100644 index 0000000000..cc71fe7bdf --- /dev/null +++ b/Darling/Darling.Tests/DarlingMcpInstructionsBudgetTests.cs @@ -0,0 +1,62 @@ +/* + * 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.Darling.Service.Mcp; +using Xunit; + +namespace Darling.Tests; + +/// +/// #3898 Phase 2 (D2, D5): the server instructions dropped their per-tool "Tool Reference" tables and prose — +/// duplicated content nothing enforced stayed in sync with the tool descriptions that are the real interface +/// (#3696, #3821 both drifted). D2's target is 8,000 characters or less; this pins the shape rather than a +/// single measured number, so a future PR can grow the instructions back toward the old size one paragraph +/// at a time without ever noticing it crossed the line back into a "Tool Reference" table. A red-watch +/// confirmed this fails against the pre-#3898-Phase-2 instructions (88,532 / 52,942 characters, and the old +/// "### Diagnostic-analysis tools" family headers present). +/// +public sealed class DarlingMcpInstructionsBudgetTests +{ + private const int InstructionsBudget = 8_000; + + [Fact] + public void Instructions_AreWithinTheD2Budget() + { + var length = DarlingMcpInstructions.Text.Length; + Assert.True(length <= InstructionsBudget, + $"DarlingMcpInstructions.Text is {length} characters; D2's budget is {InstructionsBudget}"); + } + + /// No fleet-coverage declaration reproduces exactly + /// (DarlingPeerDisclosureTests pins that identity); this just confirms the budget holds through the + /// public entry point a real host calls, not only the internal constant. + [Fact] + public void Build_WithNoDeclaredPeers_StaysWithinTheD2Budget() + { + var length = DarlingMcpInstructions.Build(DarlingPeerDirectory.Snapshot.Empty).Length; + Assert.True(length <= InstructionsBudget, + $"DarlingMcpInstructions.Build with no peers is {length} characters; D2's budget is {InstructionsBudget}"); + } + + /// The defect this whole phase fixes: a "Tool Reference" table restates what a tool's own + /// description already says, and nothing enforces that the copies agree (#3696, #3821). This fails if + /// either the old family headings or the old three-column table shape comes back wholesale — the one + /// deliberately-kept single-row "Notes" table (get_query_store_health's capture-mode footnote, + /// DarlingMcpConfigHistoryToolsTests' pin) is two columns and carries no "Key Parameters" header, so + /// it does not trip this. + [Fact] + public void Instructions_CarryNoPerFamilyToolReferenceTable() + { + var text = DarlingMcpInstructions.Text; + Assert.DoesNotContain("### Diagnostic-analysis tools", text, StringComparison.Ordinal); + Assert.DoesNotContain("### Core data-read tools", text, StringComparison.Ordinal); + Assert.DoesNotContain("### Trend data-read tools", text, StringComparison.Ordinal); + Assert.DoesNotContain("| Tool | Purpose | Key Parameters |", text, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/DarlingMcpPlanToolsTests.cs b/Darling/Darling.Tests/DarlingMcpPlanToolsTests.cs index 639bf527a8..d5306801e7 100644 --- a/Darling/Darling.Tests/DarlingMcpPlanToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpPlanToolsTests.cs @@ -181,13 +181,8 @@ public void AnalyzeDescriptions_NameCreateStatementAndImpactBasis_NotTheSuppress Assert.DoesNotContain("a hint, not a design", description, StringComparison.Ordinal); }); - /* The instructions' family paragraph names the field the same way and no longer carries #3696's - "no CREATE INDEX text". */ - var instructions = DarlingMcpInstructions.Text; - Assert.Contains("`create_statement` — the optimizer's suggested CREATE INDEX for that one statement, corroboration for a statement already measured slow and never a diagnosis", instructions, StringComparison.Ordinal); - Assert.Contains("every row carries the fixed `caveat`", instructions, StringComparison.Ordinal); - Assert.DoesNotContain("no CREATE INDEX text", instructions, StringComparison.Ordinal); - Assert.DoesNotContain("a hint, not a design", instructions, StringComparison.Ordinal); + /* #3898 Phase 2 (D5): the instructions' family paragraph that used to restate this is gone — the four + descriptions checked above are now the only surface, so nothing else to check here. */ } /* ---------------- ungated: stored-plan read SQL pins ---------------- */ diff --git a/Darling/Darling.Tests/DarlingMcpServerAdminToolsTests.cs b/Darling/Darling.Tests/DarlingMcpServerAdminToolsTests.cs index 5879344551..be1ba49003 100644 --- a/Darling/Darling.Tests/DarlingMcpServerAdminToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpServerAdminToolsTests.cs @@ -287,7 +287,8 @@ parameter list. */ } /// The tool description promises the counters and the sum; a caller reads the description, not the - /// code. + /// code. #3898 Phase 2 (D5) retired the instruction table row that used to duplicate this; the description + /// is now the only surface. [Fact] public void AddServersDescription_NamesEveryCounter_TheSumRule_AndTheCollidesStatus() { @@ -298,19 +299,16 @@ public void AddServersDescription_NamesEveryCounter_TheSumRule_AndTheCollidesSta { Assert.Contains(token, description, StringComparison.Ordinal); } - - /* And the instruction table agrees with the description — the two surfaces an agent reads. */ - Assert.Contains("`{requested, added, skipped, collided, failed, results:[{server, status, detail}]}`", DarlingMcpInstructions.Text, StringComparison.Ordinal); - Assert.Contains("is `collides`", DarlingMcpInstructions.Text, StringComparison.Ordinal); } /// /// #3484 accepted the two non-interactive Entra modes; the instruction table went on saying /// "Entra/MFA/Service-Principal/Managed-Identity auth is `invalid` (Windows/SQL only)" for a release. The - /// accepted set is read off the parser — the authority — and the table is held to it. + /// accepted set is read off the parser — the authority — and the description is held to it. #3898 + /// Phase 2 (D5) retired the instruction table row that used to duplicate this too. /// [Fact] - public void InstructionsTable_MatchesTheAuthModesTheParserAccepts() + public void DescriptionMatchesTheAuthModesTheParserAccepts() { /* The parser is the authority: these two are accepted (they parse to an entry) ... */ foreach (var accepted in new[] { "ServicePrincipal", "ManagedIdentity" }) @@ -332,15 +330,7 @@ public void InstructionsTable_MatchesTheAuthModesTheParserAccepts() Assert.Single(invalid); } - /* The table says the same: the accepted pair is named as accepted, the interactive trio as invalid, and - the release-old denial is gone in both of its spellings. */ - var row = DarlingMcpInstructions.Text.Split('\n').Single(l => l.Contains("| `add_servers` |", StringComparison.Ordinal)); - Assert.Contains("`ServicePrincipal` / `ManagedIdentity`", row, StringComparison.Ordinal); - Assert.Contains("INTERACTIVE Entra modes (MFA / device-code / default-credential) are `invalid`", row, StringComparison.Ordinal); - Assert.DoesNotContain("Windows/SQL only", row, StringComparison.Ordinal); - Assert.DoesNotContain("Service-Principal/Managed-Identity auth is `invalid`", row, StringComparison.Ordinal); - - /* And the tool description — the other surface an agent reads — agrees. */ + /* The description — the surface an agent reads — agrees. */ var method = ToolMethods().Single(m => m.GetCustomAttribute()!.Name == "add_servers"); var description = method.GetCustomAttribute()!.Description; Assert.Contains("\"ServicePrincipal\"", description, StringComparison.Ordinal); @@ -450,12 +440,8 @@ public void RemoveServerDescription_PromisesTheAmbiguousRefusal() Assert.Contains("never connected", description, StringComparison.Ordinal); Assert.Contains("ever_connected", description, StringComparison.Ordinal); Assert.Contains("matched_in", description, StringComparison.Ordinal); - - var row = DarlingMcpInstructions.Text.Split('\n').Single(l => l.Contains("| `remove_server` |", StringComparison.Ordinal)); - Assert.Contains("status:\"ambiguous\"", row, StringComparison.Ordinal); - Assert.Contains("deletes NOTHING", row, StringComparison.Ordinal); - Assert.Contains("never connected", row, StringComparison.Ordinal); - Assert.Contains("ever_connected", row, StringComparison.Ordinal); + /* #3898 Phase 2 (D5): the instruction table row that used to duplicate these facts is gone; the + description checked above is now the only surface. */ } /// diff --git a/Darling/Darling.Tests/DarlingMcpToolsTests.cs b/Darling/Darling.Tests/DarlingMcpToolsTests.cs index a0802d7612..71183e6475 100644 --- a/Darling/Darling.Tests/DarlingMcpToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpToolsTests.cs @@ -139,9 +139,10 @@ public void AuditConfig_Description_DoesNotClaimEditionAwareness_AndNamesThePost /// /// #3541 A14: the mute verb's description promises the disclosure the payload now carries — on Darling in - /// the same words as Lite (its twin pin is McpMuteReportsWhatItMatchedTests), and the instruction - /// table row agrees. The live round-trip below is what proves the numbers; this is what a caller reads - /// before deciding to trust them. + /// the same words as Lite (its twin pin is McpMuteReportsWhatItMatchedTests). #3898 Phase 2 (D5) + /// retired the instruction table row that used to duplicate this; the description is now the only surface. + /// The live round-trip below is what proves the numbers; this is what a caller reads before deciding to + /// trust them. /// [Fact] public void MuteAnalysisFinding_Description_NamesRegistered_MatchedNow_AndTheUnmatchedStatus() @@ -156,12 +157,6 @@ resolved story_path with its placeholder rule. */ { Assert.Contains(token, description, StringComparison.Ordinal); } - - var row = DarlingMcpInstructions.Text.Split('\n').Single(l => l.Contains("| `mute_analysis_finding` |", StringComparison.Ordinal)); - Assert.Contains("`matched_now`", row, StringComparison.Ordinal); - Assert.Contains("`muted_unmatched`", row, StringComparison.Ordinal); - Assert.Contains("`already_muted`", row, StringComparison.Ordinal); - Assert.Contains("`story_path`", row, StringComparison.Ordinal); } /// @@ -169,9 +164,10 @@ resolved story_path with its placeholder rule. */ /// value-banded rows with band_source / delta_sigma, the rules in band_rules, /// physical-cause families, plan_cache_churn, coverage_caveat — and says what "worse" does /// NOT mean (one window against one window is not an experiment). Same words as Lite (its twin pin is - /// CompareAnalysisDispersionTests; the shared sentences are in McpMissMessageParityPinTests), and - /// the instruction table row agrees. The banding arithmetic is pinned on the shared - /// ComparisonBanding in Lite.Tests; this is what a caller reads before trusting a verdict. + /// CompareAnalysisDispersionTests; the shared sentences are in McpMissMessageParityPinTests). + /// #3898 Phase 2 (D5) retired the instruction table row that used to duplicate this; the description is + /// now the only surface. The banding arithmetic is pinned on the shared ComparisonBanding in + /// Lite.Tests; this is what a caller reads before trusting a verdict. /// [Fact] public void CompareAnalysis_Description_SaysWhatWorseMeans_AndWhatItDoesNot() @@ -185,12 +181,6 @@ public void CompareAnalysis_Description_SaysWhatWorseMeans_AndWhatItDoesNot() Assert.Contains(token, description, StringComparison.Ordinal); } - var row = DarlingMcpInstructions.Text.Split('\n').Single(l => l.Contains("| `compare_analysis` |", StringComparison.Ordinal)); - foreach (var token in new[] { "`band_source`", "`families`", "`plan_cache_churn`", "N=1 vs N=1" }) - { - Assert.Contains(token, row, StringComparison.Ordinal); - } - /* The tool's ComparePeriodsAsync seam returns the dispersion the banding needs — a 5-tuple whose last item is the per-metric BaselineBucket map. Pinned so a twin that forgot the item would fail here rather than silently band everything by the absolute rule. */ diff --git a/Darling/Darling.Tests/DarlingPeerDisclosureTests.cs b/Darling/Darling.Tests/DarlingPeerDisclosureTests.cs index ccb0a60c46..24183f0d84 100644 --- a/Darling/Darling.Tests/DarlingPeerDisclosureTests.cs +++ b/Darling/Darling.Tests/DarlingPeerDisclosureTests.cs @@ -437,9 +437,10 @@ tool census and starts planning. */ $"the coverage section must sit between the read-only preamble and the tool census (read-only {readOnly}, coverage {coverage}, census {census})"); /* Inserting a section must not drop any of the body — the census sentence a cross-app test parses - (Lite.Tests/CrossAppMcpToolInventoryPinTests) lives in it, as does every tool table. */ + (Lite.Tests/CrossAppMcpToolInventoryPinTests) lives in it, and the workflow section (#3898 Phase 2 + trimmed the old tool tables, not the body's tail) still ends the text. */ Assert.Contains("are unique to Darling", text, StringComparison.Ordinal); - Assert.EndsWith("mute a finding pattern the operator has accepted", text.TrimEnd(), StringComparison.Ordinal); + Assert.EndsWith("`mute_analysis_finding` to silence an accepted pattern.", text.TrimEnd(), StringComparison.Ordinal); } [Fact] diff --git a/Darling/Darling.Tests/QueryStoreClutterTests.cs b/Darling/Darling.Tests/QueryStoreClutterTests.cs index f9a3404cf4..f899299821 100644 --- a/Darling/Darling.Tests/QueryStoreClutterTests.cs +++ b/Darling/Darling.Tests/QueryStoreClutterTests.cs @@ -607,17 +607,19 @@ public void TheTool_IsRegisteredOnTheHost_AndServedOnTheWebSurface() Assert.Contains("Replicas excluded by architecture", descriptor.Description, StringComparison.Ordinal); } + /// + /// #3898 Phase 2 (D5) retired the instructions' per-tool "Tool Reference" row that used to restate these + /// facts — the tool's own description (read by every MCP client regardless of instructions) is now the + /// only surface, so this pin moved onto it rather than being dropped. + /// [Fact] - public void TheInstructions_CarryTheRow_AndTheCensusSentenceNamesTheTool() + public void TheDescription_NamesTheExclusionAndCaptureModeFacts() { - var instructions = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpInstructions.cs"); - var row = Regex.Match(instructions, @"^\s*\| `get_query_store_clutter` \| (.+?) \| (.+?) \|\s*$", RegexOptions.Multiline); - Assert.True(row.Success, "DarlingMcpInstructions.cs has no Tool Reference row for get_query_store_clutter"); - Assert.Contains("qs_read_only_replica", row.Groups[1].Value, StringComparison.Ordinal); - Assert.Contains("capture_mode_known", row.Groups[1].Value, StringComparison.Ordinal); - Assert.Contains("never `NONE`", row.Groups[1].Value, StringComparison.Ordinal); - Assert.Contains("`include_fleet_median` (default false)", row.Groups[2].Value, StringComparison.Ordinal); - Assert.Contains("`get_query_store_clutter` composes the Query Store clutter view", instructions, StringComparison.Ordinal); + var source = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpQueryStoreClutterTools.cs"); + Assert.Contains("qs_read_only_replica", source, StringComparison.Ordinal); + Assert.Contains("capture_mode_known", source, StringComparison.Ordinal); + Assert.Contains("never asked, never NONE", source, StringComparison.Ordinal); + Assert.Contains("include_fleet_median", source, StringComparison.Ordinal); } [Fact] diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAgTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAgTools.cs index 5821d03cad..838a7b47d1 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAgTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAgTools.cs @@ -39,7 +39,9 @@ public sealed class DarlingMcpAgTools "log-send and redo queue sizes in KB, send/redo rates in KB/s, estimated drain minutes, secondary lag " + "seconds, and whether data movement is suspended and why). Each group is one monitored server's VIEW of " + "an AG and names that server, so an AG with several monitored replicas appears once per replica — compare " + - "them to reconcile perspectives. Severities are computed server-side and restate the DMVs' OWN verdicts " + + "them to reconcile perspectives (operational_state and recovery_health are populated only for the LOCAL " + + "replica, connected_state only from the primary — the perspectives genuinely differ by design). " + + "Severities are computed server-side and restate the DMVs' OWN verdicts " + "(states and health strings) only: lag and queue depth are NOT banded, so a badly lagging asynchronous " + "secondary whose replica health still reads HEALTHY carries a healthy severity — read secondary_lag_seconds " + "and the queue sizes yourself, and read lag together with is_suspended (the DMV reports 0 lag while data " + diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetSweepTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetSweepTools.cs index b54064e812..9468a06f1e 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetSweepTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetSweepTools.cs @@ -68,7 +68,8 @@ public sealed class DarlingMcpFleetSweepTools "instruments_alive: false means the sweep could NOT prove its own data sources - a dead reader, a " + "silent fleet outside the post-restart settle window, or a frozen alert-pass counter beside a " + "delivering path. Read the instrument_liveness block before believing any quiet card on that sweep: " + - "an empty window there is unreadable, not healthy. " + + "an empty window there is unreadable, not healthy. Sweep content reaches the alert channels through " + + "at most one daily rollup (INFO, master-gated); this tool and the web feed are the full-cadence record. " + "Watch items carry entry/exit hysteresis (the bars ride the payload beside the counters); the default " + "worklist view is open plus carried, because open lasts exactly one sweep by design - ask for a named " + "state (pending, open, carried, closed) with watch_state. A stored read over the monitoring store; no " + diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs index a3d3d26066..3e4fa2baad 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright (c) 2026 Erik Darling, Darling Data LLC * * This file is part of the SQL Server Performance Monitor. @@ -9,10 +9,16 @@ namespace PerformanceMonitor.Darling.Service.Mcp; /// -/// Server instructions sent to MCP clients during initialization — Lite's McpInstructions -/// framing (read-only posture, collection-freshness notes, tool reference) scoped to the ~87 -/// analysis + plan-analysis + data-read tools (plus the Custom Views + alert-tuning + server-onboarding -/// write surfaces) this headless service exposes (the body enumerates them). +/// Server instructions sent to MCP clients during initialization. #3898 Phase 2 (D5): the per-tool "Tool +/// Reference" tables and their prose are gone — a tool's own description in tools/list is the +/// authoritative surface for what it does and what it takes, and the two were duplicated here only because +/// nothing enforced that the copies stayed in sync (several didn't: #3696, #3821). What stays is what a +/// single tool's own description cannot say by itself: cross-tool contracts (the five/six-word status +/// vocabulary, the as_of/hours_back anchor rule), and specific traps proven to cause a +/// misreading (AG's per-replica columns, the PostgreSQL wait-instrument tiers, the Query Store capture-mode +/// footgun). The one-line census (D5) still names every family so an agent knows the surface exists; it does +/// not re-describe each tool. Issue references, anecdotes and dropped-content notes belong in the CHANGELOG, +/// not here (D4) — see #3898 for the ones this rewrite carried off the wire. /// internal static class DarlingMcpInstructions { @@ -47,22 +53,17 @@ public static string Build(DarlingPeerDirectory.Snapshot peers) - Modify, insert, or delete any collected data - Run any ad-hoc diagnostics beyond what the collectors have already captured - The writes this server performs are all to the MONITORING store, never a monitored SQL Server: mute_analysis_finding records a mute rule, analyze_server persists its findings, the custom-view management tools (create_custom_view / update_custom_view / delete_custom_view) save user-authored dashboard/notebook definitions to config.custom_views, and the alert-tuning tools (update_alert_settings / create_mute_rule / update_mute_rule / delete_mute_rule / set_mute_rule_enabled) change the shared alert configuration the service delivers on (all the same store the web viewer / Settings window writes). None of these touches a monitored SQL Server or the collected performance data itself. + The writes this server performs (analysis mutes, Custom Views authoring, alert tuning, server onboarding) all land in the MONITORING store — the same store the web viewer and Settings window write. None of them touches a monitored SQL Server or the collected performance data itself. """; private const string Body = """ ## How Data Is Collected - The Darling service collects from monitored SQL Server instances 24/7 and stores the data in a Postgres/TimescaleDB store. Data is collected in snapshots at regular intervals (typically every 1-15 minutes depending on the collector). This means: + The Darling service collects 24/7, every 1-15 minutes depending on the collector. Data is only as fresh as the last collection. `server_name` resolves against the registry (exact, then partial). `analyze_server` needs 24+ hours of history (`wait_stats` for a SQL Server target, `pg_database_stats` for a PostgreSQL target) or returns `insufficient_data`. - - Data is only as fresh as the last collection cycle. - - Wait stats represent delta values since the last collection, not instantaneous snapshots. - - All tools accept a `server_name` parameter. If only one server is monitored, it's used automatically. Names resolve against the service's server registry (exact match first, then partial, against the storage name and the display name). - - Analysis needs at least 24 hours of collected history for a server; before that, analyze_server returns `insufficient_data`. The history is measured on the engine's own one-minute series: `wait_stats` for a SQL Server target, `pg_database_stats` for a PostgreSQL target. The analysis pass routes by the registry's `engine_kind`, so a PostgreSQL target is analyzed by the PostgreSQL-target engine (facts keyed `PG_*` / `CONFIG_PG_*` / `ANOMALY_PG_*`, sources `pg_*`), never by the SQL Server one. + ## Tools - ## Tool Reference - - This server exposes 158 tools. 87 are the same names Performance Monitor Lite exposes, spanning diagnostic analysis, plan analysis, data reads at core and diagnostic depth, resource contention + jobs, trends, system-health parse-on-read, alerts + health overview, and the Default Trace. The remaining 71 are unique to Darling: thirty-five are the PostgreSQL reads (Aurora/PostgreSQL targets only Darling's central store can hold; two of them are the target-onboarding pair — `get_pg_plan_capture_readiness` judges plan capture's preconditions facet by facet and `get_pg_logging_audit` judges the rest of the logging surface setting by setting from the stored configuration, each with the remedy per finding in the hosting flavour's syntax — which together answer whether a target is telling us everything it could), eight are the Custom Views tools (seven manage the saved views — the one view-authoring write surface — and `describe_custom_view_catalog` returns the read-only compose vocabulary those authoring tools draw from), eight are the custom-alert-rule tools (`create_custom_alert_rule` / `update_custom_alert_rule` / `delete_custom_alert_rule` manage the user-authored alert rules, the one alert-authoring write surface, while `get_custom_alert_rule` / `list_custom_alert_rules` read them back, `validate_custom_alert_rule` checks a rule definition against the same compose catalog without saving it, `test_custom_alert_rule` evaluates a rule's metric NOW on each in-scope server and reports whether it would breach without delivering or persisting anything, and `list_custom_alert_templates` lists the built-in starter rule templates to browse and create from), seven are alert-tuning write tools (`update_alert_settings` tunes the alert engine's thresholds; `create_mute_rule` / `update_mute_rule` / `delete_mute_rule` / `set_mute_rule_enabled` manage the mute rules, `update_mute_rule` editing a rule in place and `set_mute_rule_enabled` taking one out of force and putting it back — both without destroying it or resetting its creation date; `set_notification_route_enabled` / `delete_notification_route` take a notification route out of force or remove it — routes decide WHERE an alert family is delivered and are authored in the Viewer's Settings window, since a destination is a bearer secret this surface never holds) that write only the shared alert configuration in the monitoring store, two are server-onboarding write tools (`add_servers` bulk-adds monitored servers; `remove_server` removes one) that add or remove rows in the monitoring store's monitored-server registry, `get_fleet_overview` and `get_ag_health` are the two cross-server reads of the fleet's CURRENT state only a central store can answer, `get_sweep_reports` is the cross-server read WITH MEMORY — the scheduled fleet sweep's persisted whole-fleet reports (the timeline with each sweep's document embedded, one sweep in full with its would-have-paged ledger, and the watch-item worklist), `get_store_metrics` reads the monitoring store's OWN hourly size/compression/growth series for capacity forecasting, `get_store_log` reads what the monitoring store's OWN PostgreSQL server log recorded as a per-class census with its capture denominator (the self-monitoring that shows the store's half of a client-side symptom), `get_store_query_stats` ranks the monitoring store's OWN SQL statements by server-side cost, split by the role that ran each one (the web viewer, MCP tools, the Darling Viewer, or the service itself: the answer to which query makes a slow read slow), `get_collector_cost` reads the tool's OWN per-collector cost on the monitored servers (the self-monitoring that flags a collector regressing into a hog), `get_collector_stall_probes` reads the out-of-band server-wide wait samples this tool takes while one of its own collectors is stalled mid-read — the only surface here that reports what a monitored instance was doing inside the window the sequential sweep records nothing in — `get_oversized_plan_backlog` reads the backlog of cached plans the capture cap declined and what the out-of-band sweep has since done about each one (the self-monitoring that tells a sweep whose fetch half is working from one that has never once succeeded), `get_query_store_clutter` composes the Query Store clutter view from rows already collected — per database the collector's read cost, plan churn and the options row with a decomposed verdict, plus one per-server overhead block of the non-sleep `QDS_*` waits and the Query Store memory clerk, replicas excluded by architecture (Darling-only today because every input is a stored read the headless service already holds; Lite has the same inputs and the twin is a port, not a boundary), and `get_blocking` is Darling's name for the blocked-process-report read that Lite exposes as `get_blocked_process_reports` — a naming difference, not a capability gap. Every data-read tool reads the data the collectors already captured into the store — a stored read, never a live query against the monitored server. + This server exposes 158 tools. 87 are the same names Performance Monitor Lite exposes. The remaining 71 are unique to Darling: thirty-five are the PostgreSQL reads (`get_pg_*`), plus Custom Views, custom-alert-rule, alert-tuning and server-onboarding tools, and central-store-only reads (`get_fleet_overview`, `get_ag_health` — Availability Groups — `get_sweep_reports`, `get_store_metrics`, `get_store_log`). `get_blocking` = Lite's `get_blocked_process_reports`. ### Reading an empty result @@ -72,273 +73,26 @@ public static string Build(DarlingPeerDirectory.Snapshot peers) ### PostgreSQL waits come from one of three instruments - A PostgreSQL target's wait history is fed by exactly ONE of three instruments, chosen once when the service connects to it, in this order of preference: Aurora native > the `pg_wait_sampling` extension > the service-side sampler. Aurora targets get `engine_cumulative` — the engine's own wait counters and measured wait TIME, read by `get_pg_wait_stats`. Stock and RDS targets are read by `get_pg_wait_sampling`, whose `instrument` field says which of the other two fed the rows: `extension_sampled` is the extension's in-engine 10 ms profiler; `service_sampled` means the extension is not installed and this service polled `pg_stat_activity` once a second for a 30-second window every five minutes instead. That last tier is a FLOOR, not parity — it under-counts waits shorter than a second and sees nothing between windows, and `instrument_note` says so in the answer — but it is what stops a first-install stock target from reading as "no waits at all". A count only means something beside its instrument: read `instrument` before comparing two servers' wait profiles, and treat `service_sampled` shares as trustworthy for steady fractions of the server's time and approximate for rare short events. Installing `pg_wait_sampling` (a `shared_preload_libraries` entry, then `CREATE EXTENSION` in the monitored database, then a reconnect) moves a target to the finer tier with no other change. + A PostgreSQL target's wait history comes from exactly ONE instrument: Aurora native > the `pg_wait_sampling` extension > the service-side sampler. Aurora gets `engine_cumulative`; others read `get_pg_wait_sampling`'s `instrument`: `extension_sampled` (10 ms profiler) or `service_sampled` (no extension; once-a-second poll) — a FLOOR, not parity, but better than none. ### Asking about a PAST window - Every tool below that takes `hours_back` also takes `as_of`: an optional ISO-8601 UTC instant that moves the END of the window off "now". `hours_back` stays the window's LENGTH. So the four hours around last Tuesday 03:00 is `as_of=2026-08-19T05:00:00Z, hours_back=4` — not `hours_back=170`. - - Reach for it whenever the question is about a time rather than about the present, and do NOT substitute a wider `hours_back`: for an aggregate read a wider window is a DIFFERENT answer, not the same answer with more rows. It changes what a top-N returns, what an average is taken over, and how much a capped read truncates. - - - Accepted forms: `2026-08-19T05:00:00Z`, `2026-08-19T05:00:00` (read as UTC), `2026-08-19T07:00:00+02:00`, `2026-08-19` (midnight UTC). - - An unparseable `as_of`, or one in the future, is REFUSED with a message rather than quietly answered as "now" — a read that silently reverts to now is indistinguishable from a correct one. - - An `as_of` older than anything the store still holds is NOT refused. It returns the read's normal `empty` / `unavailable` status, which means exactly what it says: we looked in the window you named and there was nothing in it. - - Tools that take no window at all (latest-snapshot reads like `get_memory_stats`, `get_file_io_stats`, `get_index_usage`, and the configuration reads) do not take `as_of` — they read the newest row, and there is no window to move. - - **Latest is a time.** Every latest-snapshot read publishes `captured_at` — the snapshot's own collection instant — and the anchored ones publish `age_seconds` against the window's end. Read it before treating a "current" figure as current: the newest row a store holds is as old as its collector's last successful run, and the configuration family is captured on CONNECT, so a "current" setting can be days old. Where a latest read takes `hours_back`, its description says which of two things that means: the span SEARCHED for the newest snapshot (`get_latch_stats`, `get_cpu_scheduler_pressure`, `get_plan_cache_bloat` — a snapshot older than that is `unavailable`, not served as current), or a span READ beside the snapshot (`get_resource_semaphore` / `get_memory_grants` return `grants[]`, the newest snapshot, AND `window[]`, the peak / floor / summed-delta aggregate over every snapshot in the hours). `get_server_summary` carries three clocks by name — `cpu_captured_at`, `memory_captured_at`, and `last_collection` (the newest collection of ANY collector, which is the store's freshness and not the age of the two figures). - - The analysis family DOES take it (#2506), and the anchor reaches the ENGINE rather than stopping at the tool: `get_analysis_facts` and `analyze_server` re-run fact collection, anomaly detection and scoring over the anchored window, so the window is compared against the hour-of-day x day-of-week baseline for the hours it actually covers instead of for the hours you happen to be asking in. `compare_analysis` hangs BOTH windows off the anchor, since `baseline_hours_back` has always been measured from the comparison window's end. `get_analysis_findings` is the odd one and worth reading twice: its window is on ANALYSIS TIME, so anchoring it asks what a scheduled analysis pass was SAYING then, which is a different question from re-analyzing that window now (that is `analyze_server` with the same anchor). - - `analyze_server` with an `as_of` is EXPLORATORY and does NOT persist its findings; the result says so in `persisted` / `persistence_note`. A finding row is stamped with the time the analysis RAN, and `get_analysis_findings` and the viewer's Recommendations tab treat the newest `analysis_time` as the server's CURRENT state — so writing a backdated run would make last week's findings today's headline and would inflate the occurrence stats of any live incident sharing a story path. Run it without `as_of` when you want the present analyzed and recorded. - - `get_pvs_stats` and `get_fleet_overview` do not take it. Each mixes a latest-snapshot measurement with a windowed one, so anchoring only the windowed half would return a result whose two halves describe different instants. `get_store_metrics` does not take it either: it windows in DAYS over the store's own growth series. - - ### Diagnostic-analysis tools - - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `analyze_server` | Runs the inference engine: scores facts, traverses relationship graph, returns evidence-backed findings with severity and recommended next tools. Each finding's `confidence` is an EVIDENCE score (0.20 for the symptom alone, more as the root fact's amplifier checks match and the chain deepens — a lone uncorroborated symptom is 0.20, never 1.0) and `confidence_basis` says in words what it rests on; rank by `severity` for impact and read `confidence` as how much of the engine's own corroboration showed up. A remediable finding also carries `remediation_command` — the full copy-paste T-SQL remediation (identical to the viewer card), with a two-sided risk-disclosure header on destructive changes; advisory only, never executed. Force-plan findings additionally carry `structured_remediation`: the verdict as machine-readable fields (eligible + named blockers), evidence, and split force/unforce/verify SQL. With `as_of` it analyzes a PAST window — anomaly baseline included — and is EXPLORATORY: the findings come back in full but are NOT persisted, which `persisted` / `persistence_note` state on every result | `server_name`, `hours_back` (default 4), `as_of` | - | `get_analysis_facts` | Exposes raw scored facts from the collect+detect+score pipeline — every observation the engine sees, the anomaly detector's ANOMALY_* facts included, with base severity, amplifiers, and metadata (an ANOMALY_* fact's `baseline_confidence` is the baseline's trustworthiness, not a finding's `confidence`) | `server_name`, `hours_back` (default 4), `source` (one of the engine's 15 registered sources — anomaly, bad_actor, blocking, config, coverage, cpu, database_config, disk, io, jobs, memory, queries, sessions, tempdb, waits; an unknown value is refused with the set), `min_severity`, `as_of` | - | `compare_analysis` | Compares two time periods (e.g., peak vs off-peak, yesterday vs today, the windows around a change), banding each fact worse / better / stable by how far its VALUE moved on the server's own scale — in the stored per-server baseline's robust sigma where one exists (`delta_sigma`, `band_source` `baseline`), otherwise only when the value moved at least a quarter of the larger side AND registers a quarter of the way up its own severity ladder (`band_source` `absolute`); `band_rules` states the rules on every payload. Rows are grouped into physical-cause `families` (one I/O stall is one family row), and `BAD_ACTOR_` appearances are `plan_cache_churn`, not new or resolved issues. A verdict is a DIFFERENCE, not an experiment: same-hour-yesterday at N=1 vs N=1 cannot show that a change caused anything. When NEITHER window produced facts the result is `unavailable` rather than an all-zero comparison, because "nothing to compare" is not "nothing changed"; when only ONE window is empty the payload carries a `caveat` saying so; a partly collected side flags every verdict row with `coverage_caveat`. `baseline_hours_back` is measured from the comparison window's END, so `as_of` moves BOTH windows together | `server_name`, `hours_back` (default 4), `baseline_hours_back` (default 28), `as_of` | - | `audit_config` | Configuration audit. SQL Server: evaluates CTFP, MAXDOP, max memory, and max worker threads against best practices and the server's resources (edition is reported, not consulted). PostgreSQL: projects the pass's `CONFIG_PG_*` setting facts into the same recommendations shape with `engine` in place of `edition`, `current_value` carrying its unit and no `suggested_value` (the recommendation sentence carries the evidence), and `checkpoint_timeout` / `max_wal_size` rendered `not_applicable` on Aurora | `server_name` | - | `get_analysis_findings` | Retrieves persisted findings from previous analysis runs (each with `confidence_basis`: rows persisted before `confidence` measured corroboration are labelled `path-shape (pre-#3538)` — under that formula a lone symptom read 1.0, so do not read those as corroborated) (the service also analyzes on its own schedule, every 30 minutes per server), deduplicated to one entry per diagnostic chain (`story_path_hash` + `incident_id`): the latest occurrence plus `occurrences`/`first_seen`/`last_seen`/`peak_severity` spanning the window; each remediable finding carries `remediation_command` — the full copy-paste T-SQL remediation (identical to the viewer card), rendered from the persisted action, advisory only and never executed; force-plan findings additionally carry `structured_remediation` (verdict + evidence + split artifacts, machine-readable). Its window is on ANALYSIS TIME, so `as_of` asks what analysis was saying then rather than re-analyzing that window now | `server_name`, `hours_back` (default 24), `as_of` | - | `mute_analysis_finding` | Mutes a finding pattern by story_path_hash so it won't appear in future runs. Idempotent per scope: the same hash muted twice in the same scope writes nothing the second time (status `already_muted`, `registered` false). Reports what the write did: `registered`, `already_muted`, `story_path` (the chain the row names, null when no retained finding carries the hash), and `matched_now` — how many stored findings in scope carry the hash (status `muted_unmatched` when 0: the mute is kept, but check the hash) | `story_path_hash` (required), `server_name`, `reason` | - - ### Plan-analysis tools - - These run the shared execution-plan analyzer over the plan XML the collectors already captured into the store (a STORED-plan read — no live query to the monitored server), returning warnings, missing indexes (column lists, the statement-scoped impact estimate labelled `impact_basis`, and `create_statement` — the optimizer's suggested CREATE INDEX for that one statement, corroboration for a statement already measured slow and never a diagnosis; every row carries the fixed `caveat`: the estimate is per-statement, an index is a per-table commitment with write cost and regression risk for other plans, so test it), parameters, memory grants, and a stated cut of top operators (`operators_returned` / `total_operators` / `truncated`, ranked by `operators_ranked_by`). - - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `analyze_query_plan` | Analyzes a stored query-stats plan by query_hash | `query_hash` (required), `server_name`, `database_name` (optional refinement) | - | `analyze_procedure_plan` | Analyzes a stored procedure-stats plan by sql_handle | `sql_handle` (required), `server_name` | - | `analyze_query_store_plan` | Analyzes a stored Query Store plan by database + query_id | `database_name` (required), `query_id` (required), `server_name`, `plan_id` (optional refinement) | - | `analyze_plan_xml` | Analyzes raw showplan XML passed directly (no fetch) | `plan_xml` (required) | - | `get_plan_xml` | Returns the raw stored plan XML for a query by query_hash (truncated at 500KB) | `query_hash` (required), `server_name`, `database_name` (optional refinement) | - - ### Core data-read tools - - These read the collected metrics directly. Resource-metric tools accept `hours_back`; discovery/health tools take no window. - - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_cpu_utilization` | CPU % over time (SQL / other-process / total / idle), bucketed | `server_name`, `hours_back` (default 4), `as_of`, `bucket_minutes` | - | `get_wait_stats` | Top wait types aggregated over the window (wait/signal/resource ms, signal %) | `server_name`, `hours_back` (default 24), `limit` (default 20), `as_of` | - | `get_wait_trend` | One wait type's per-second trend over time | `wait_type` (required), `server_name`, `hours_back` (default 24), `as_of`, `bucket_minutes` | - | `get_wait_types` | The distinct wait types observed on the server (heaviest first) — pick a `wait_type` for get_wait_trend. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server no wait stats have ever been stored for (`unavailable`) | `server_name`, `hours_back` (default 24), `as_of` | - | `get_memory_stats` | Latest memory snapshot: physical / buffer pool / plan cache / utilization %, memory model; `captured_at` | `server_name` | - | `get_memory_clerks` | Latest top memory consumers by clerk type. An empty result is `unavailable`, never a quiet period — a live SQL Server always has clerks, so nothing retained means the collector has not run or its rows aged out | `server_name` | - | `get_file_io_stats` | Latest per-file I/O: reads/writes/bytes/stall and computed read/write latency; `captured_at` | `server_name` | - | `get_tempdb_trend` | TempDB space over time (user / internal / version store / unallocated) + top consumer | `server_name`, `hours_back` (default 24), `as_of`, `bucket_minutes` | - | `get_perfmon_stats` | Latest perfmon counters (value + delta); filter by counter / instance; `captured_at` | `server_name`, `counter_name`, `instance_name` | - | `get_top_queries_by_cpu` | Expensive queries from query stats (plan cache) with query_hash / sql_handle; `cpu_attribution.attributed_cpu_ratio` says how much of the box's measured CPU the returned rows explain. `parallel_only` / `min_dop` are applied IN the query before the ranking and the cap (`filter_applied` names the floor), so the page is the top-N of the parallel population | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name`, `parallel_only`, `min_dop`, `as_of` | - | `get_top_procedures_by_cpu` | Most expensive stored procedures by total CPU, with the same `cpu_attribution` disclosure | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name`, `as_of` | - | `get_query_store_top` | Expensive queries from Query Store with query_id / plan_id (survives restarts). Reads the raw tier only, so `effective_start` / `effective_hours_back` say how far back the read reached and `window_truncated` says the raw tier did not hold the whole window (spelled `truncated` before #3653 — the window floor, not a page cut; `top` is a rank, and raising it changes nothing here) | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name`, `as_of` | - | `get_query_heatmap` | The desktop viewer's Query Heatmap as a TABLE: how many DISTINCT queries fell into each (time bin x log-magnitude bucket) cell, with the most-executed query in each cell. The only query read with a TIME axis — `get_top_queries_by_cpu` ranks a whole window and cannot show that the window had a quiet half and a bad half, which is the first question about an incident that has already ended. Bins are **5 minutes** wide by default because that is exactly what the desktop viewer uses, so both surfaces draw the same picture; raise `bucket_minutes` to cover a longer window in fewer cells (it is the lever to reach for before the cap). The seven magnitude buckets are the viewer's, in the metric's own unit, and the labels come back with the result. `limit` caps CELLS, and truncation drops the OLDEST bins rather than the least interesting cells — `first_time_bin` / `last_time_bin` say which slice came back. Zero cells is THREE states and the read says which: never collected (`unavailable`, nobody looked), nothing collected in the window (`empty`, widen it), or collected and genuinely idle — captures exist and every one recorded zero executions (`empty`) | `server_name`, `hours_back` (default 24), `metric` (default `duration`), `database_name`, `bucket_minutes` (default 5), `limit` (default 500), `as_of` | - | `get_query_store_regressions` | Queries whose Query Store performance got WORSE: each (database, query_id) group's averages inside the recent window vs its BASELINE — every capture collected BEFORE that window. Baseline vs recent duration / CPU / logical reads with a regression percent each, the execution-count-weighted `additional_duration_ms` (the ranking key: a 5 ms regression run a million times outranks a 5-second one run twice), the plan counts on both sides, and a severity band. `get_query_store_top` answers what is EXPENSIVE and the costliest query is usually the one that always was; this answers what CHANGED. Kept only where average CPU regressed > 25%. Zero rows is FOUR states and the read says which: never collected (`unavailable`), no BASELINE because all history falls inside the window (`unavailable`, and NOT a clean bill of health — shorten hours_back), nothing collected in the window (`empty`, widen it), or a genuine all-clear (`empty`) A percent whose baseline side is 0 (e.g. no logical reads before the window, 50k inside it) has no denominator and is null with the reason under `undefined_percents` — never 0, which would read as no change; the ranking key is an absolute delta and exists for every row, and `severity` is null when the duration percent is. | `server_name`, `hours_back` (default 24), `database_name`, `limit` (default 50), `as_of` | - | `get_query_store_clutter` | The Query Store CLUTTER view for one server, composed from rows already collected (no new query against the target). Per DATABASE, three decomposed arms with raw numbers: `read_cost` (the query_store collector's fan-out rollup — `runs_slowest` / `runs_slowest_pct`, `slowest_share_pct` = the slowest item's median share of the pass, `dominance_ratio` = this database's median slowest cost over the other databases' pooled median), `plan_churn` (raw `query_store_stats` by `plan_id`: `plans_per_query_p95` / `_max`, `new_plans_per_day`, `never_seen_twice_fraction`, `distinct_queries` / `distinct_plans`) and `config` (the newest `query_store_health` capture in the window with its `captured_at`; `query_capture_mode` is the V137 (#3796) column verbatim — `ALL` / `AUTO` / `CUSTOM` / `NONE`, null meaning a capture older than the rung and never `NONE` — with `capture_mode_known` beside it). `verdict` is the band canon with `verdict_reasons` and the bars under `thresholds`; Unknown means unmeasured. A database whose `readonly_reason` carries bit 8 (a readable secondary) is `excluded: true, excluded_reason: qs_read_only_replica` — by architecture, never a defect. ONE per-server `qs_overhead` block: the non-sleep `QDS_*` wait deltas with `wait_ms_per_hour` over the measured span and `excluded_wait_types` naming the four sleep waits dropped at collection, plus `MEMORYCLERK_QUERYDISKSTORE` latest and window max. Rows worst-first, cut at `limit` (`truncated`, `databases_returned`, `database_count`); `window_truncated` says the raw tier did not hold the whole window. `fleet_median` is opt-in. | `server_name`, `hours_back` (default 24), `limit` (default 50), `include_fleet_median` (default false), `as_of` | - | `list_servers` | All monitored servers with collection-freshness status and last collection time. Each row names its engine: `engine_kind` is the registry token (`sqlserver` / `postgres` / `aurora-postgres`, null before any connect stamps the row) and `engine_version` the engine-aware version label ("SQL Server 2022", "PostgreSQL 18"); `sql_version` is a DEPRECATED alias of `engine_version` kept for existing consumers — do not read an engine from its name. Plus `peer_fleets` — the declared SIBLING Darling stores and what each covers (disclosure only; this server cannot read them) and `peer_note`, which says what an EMPTY `peer_fleets` does and does not prove | none | - | `get_collection_health` | Per-collector health (running / failing / stale) over the last 7 days, plus the server's sweep_pressure block: a `verdict` for SUSTAINED demand (a SATURATED body collects at a multiple of its configured cadence with every collector healthy) and a separate `peak_cycle_risk` for a SINGLE sweep (BODY_OVERRUN means one scheduled body cannot fit the budget even when the verdict reads OK, the signature of one infrequent heavy collector; `peak_collector` names it). Per-collector rows carry `avg_duration_ms`, `p95_duration_ms` and `max_duration_ms`: a mean far below the p95 means the collector's runs come in two sizes and the mean describes neither. `collection_health_age_seconds` says how old the answer is — the 7-day scan is memoized per server for 60 s (#3856), the same shape as `get_fleet_overview`'s, so repeat calls inside a minute read the memo rather than racing the scan; carries analysis_caveats when a recent analysis pass could not read a fact family | `server_name` | - | `get_collection_log` | The RAW per-run collection log behind that rollup: one row per collector run with total duration split into time on the monitored server and time on the store, rows collected, status and any error. Reach for it when the rollup reads HEALTHY and collection still looks wrong, or to see what a collector was doing during a specific window. Newest first, or SLOWEST first when `min_duration_ms` is supplied — a duration floor under newest-first ordering cannot reach the tail, so the two are one decision and `order` names which you got. All THREE filters are applied in SQL BEFORE the cap, so `run_count` and `truncated` describe the MATCHING rows. `status` is the FAILURE filter and the one to reach for during an incident — "show me the failures" is what this log exists to answer, and paging the newest-first tail cannot reach a failure that is not recent; an unknown value is REFUSED naming the accepted set, never applied as a filter that matches nothing, and the applied value is echoed as `status_filter` (not `status`, which on a miss is the miss word). `hours_back` is the span you asked for; `oldest_returned_collection_time` and `newest_returned_collection_time` bound the PAGE you got (under the default ordering that is also the reach; under a duration floor the page is a cost-ranked sample and its oldest row says nothing about reach), and the cap can make those differ by orders of magnitude — read them before concluding anything from the rows. An empty result distinguishes THREE states: filters that matched nothing (`empty`, and it says nothing about the window as a whole), a quiet window (`empty`, widen it), and a server that has never collected (`unavailable`, collection is not running) | `server_name`, `hours_back`, `limit`, `as_of`, `collector_name`, `min_duration_ms`, `status` | - | `get_current_waits_trend` | The two Current Waits series over time: waiting-task total wait per wait type per collection, and blocked-session counts per database per collection. `get_waiting_tasks` gives the snapshot and can never say whether now is worse than an hour ago; this is that question. Read the two series together — a wait-type spike with no blocked sessions is a resource wait, the same spike with them is contention. An empty result distinguishes a genuine all-clear (`empty`) from a server the collector has never sampled (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back`, `database_name`, `as_of` | - | `get_blocking_stats` | Blocking SEVERITY per minute: blocking duration (event count, total, max, avg wait) and deadlock severity (victim count plus total/max/avg wait across EVERY process in the graphs, not just victims). `get_blocking_trend` and `get_deadlock_trend` say how OFTEN; this says how BAD — ten one-second blocks and one ten-minute block are the same count and a different problem. An empty result distinguishes a genuinely clear window (`empty`) from a server where neither capture path has ever produced a row (`unavailable`), which is NOT a clean bill of health | `server_name`, `hours_back`, `as_of` | - | `get_server_properties` | Instance properties: edition, version, CPU count, memory, socket/core topology, HADR | `server_name` | - - ### Diagnostic-depth data-read tools - - Deeper reads for a blocking / deadlock / session / configuration / storage investigation. Same names + parameters Lite and the Dashboard expose; where the two SKUs' result shapes diverge these follow Lite (the store-faithful shape). - - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_blocking` | Recent blocked/blocking pairs from the blocked-process-report XE + the always-on DMV fallback, newest first. The page is bounded by `limit`, not `hours_back`: read `truncated` and `oldest_returned_event_time` before treating the page as the window | `server_name`, `hours_back` (default 24), `limit` (default 30), `dedup_key` (optional), `as_of` | - | `get_deadlocks` | Recent deadlocks: victim process/SQL + a process summary, newest first. Page bounded by `limit`; `truncated` and `oldest_returned_deadlock_time` say what it reached | `server_name`, `hours_back` (default 24), `limit` (default 20), `dedup_key` (optional), `as_of` | - | `get_deadlock_detail` | The raw deadlock graph XML for the recent deadlocks, newest first; `limit` counts graphs, `truncated` says the window held more | `server_name`, `hours_back` (default 24), `limit` (default 5), `dedup_key` (optional), `as_of` | - | `get_blocked_process_xml` | The raw blocked-process-report XML, newest first; `limit` counts reports with XML, `truncated` says the window held more | `server_name`, `hours_back` (default 24), `limit` (default 5), `as_of` | - | `get_long_query_completions` | The window's SLOWEST completed queries (rpc/batch over the trace threshold) + attentions/cancels from the opt-in long-query trace, duration DESC (empty until the collector is enabled). `truncated` says the window held more than `limit`; the page's time stamps bound the slowest runs, not the reach | `server_name`, `hours_back` (default 24), `limit` (default 30), `as_of` | - | `get_blocking_trend` | Per-minute blocking-incident counts over time (XE, DMV-snapshot fallback). An empty result distinguishes a genuine all-clear (`empty`, with the collector run counts in `hints` so you can see how many captures the window actually holds) from a window no collector covered (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back` (default 24), `as_of` | - | `get_deadlock_trend` | Per-minute deadlock counts over time. An empty result distinguishes a genuine all-clear (`empty`, with the collector run counts in `hints` so you can see how many captures the window actually holds) from a window no collector covered (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back` (default 24), `as_of` | - | `get_lock_wait_trend` | The whole LCK% family's wait milliseconds per SECOND, summed across types and bucketed, with a `wait_types` legend of the types that waited — the aggregate lock-wait lane. The two trends above count incidents and `get_wait_trend` charts ONE named wait type; this shows lock pressure rising when no single type dominates, as a rate that compares across cadences. An empty result distinguishes a genuinely quiet window (`empty`, widen `hours_back`) from a server no wait stats have ever been stored for (`unavailable`), which is NOT a report of a server without lock contention | `server_name`, `hours_back` (default 24), `as_of`, `bucket_minutes` | - | `get_session_stats` | Latest per-application connection/session counts (running/sleeping/dormant) + resource totals | `server_name` | - | `get_active_queries` | Captured running-query snapshots over the window (waits, CPU, blocking, grants). `database_name` / `blocking_only` are applied IN the query; `total_snapshots` counts the filtered population, `snapshots_returned` the page, `truncated` says the population held more. Head blockers a victim names are never stripped (`is_head_blocker`); a victim whose blocker is absent says why in `blocker_not_shown` (`not_captured` / `filtered` / `past_page`) | `server_name`, `hours_back` (default 1), `database_name`, `blocking_only`, `limit` (default 50), `as_of` | - | `get_waiting_tasks` | Individual waiting tasks captured at collection time, newest capture first. Page bounded by `limit`; `truncated` and `oldest_returned_collection_time` say what it reached | `server_name`, `hours_back` (default 1), `limit` (default 30), `as_of` | - | `get_server_config_changes` | sp_configure changes, diffed from config snapshots | `server_name`, `hours_back` (default 168), `as_of` | - | `get_database_config_changes` | sys.databases setting changes, diffed from config snapshots | `server_name`, `hours_back` (default 168), `as_of` | - | `get_trace_flag_changes` | Trace flags enabled/disabled/modified, diffed from config snapshots | `server_name`, `hours_back` (default 168), `as_of` | - | `get_database_scoped_config` | Latest database-scoped configuration (MAXDOP, legacy CE, ...) as of `captured_at` (captured on connect) | `server_name`, `database_name` | - | `get_query_store_health` | Per-database Query Store health (latest hourly snapshot) — actual vs desired state, readonly_reason decoded, storage vs cap, cleanup thresholds, and the two capture modes (`query_capture_mode` ALL / AUTO / CUSTOM / NONE — ALL is the plan-churn factory; `wait_stats_capture_mode` ON / OFF; null = pre-rung row or pre-2017 engine) | `server_name`, `database_name` | - | `get_server_config` | CURRENT sys.configurations (latest snapshot) — what CTFP / MAXDOP / max memory are set to as of `captured_at` (captured on connect — can be days old) | `server_name` | - | `get_database_config` | CURRENT per-database settings (latest snapshot) — recovery model, RCSI, Query Store, ... as of `captured_at` (captured on connect) | `server_name`, `database_name` | - | `get_trace_flags` | CURRENT active trace flags (latest snapshot) — flag number, enabled, global/session, as of `captured_at` (captured on connect) | `server_name` | - | `get_table_index_sizes` | The 100 largest tables with size + growth from the latest daily snapshot. Growth spans only history the store holds: `history` says how many days exist and whether the 7d/30d baselines are reachable; `growth_7d_mb` / `growth_30d_mb` / `growth_pct_30d` are null (reason in `growth_note`) when their baseline does not exist — never re-measured over a shorter span under the same name — and `growth_over_available_history_*` spans exactly `growth_window_days` | `server_name` | - | `get_index_usage` | Per-index usage classified Unused / Write-only / Active | `server_name` | - | `get_object_locking` | Per-index lock/latch contention, most contended first, at the newest index/object snapshot — `captured_at` is the instant it was collected, and the collection is DAILY, so on a healthy server the stamp is routinely hours old | `server_name` | - | `get_database_sizes` | Per-file database sizes, space usage, and volume free space | `server_name` | - - ### Resource-contention + jobs data-read tools - - Deeper reads for an internal-contention / worker-thread / memory-grant / plan-cache / SQL Agent investigation. Same names + parameters Lite and the Dashboard expose. The Dashboard's per-class latch `severity` / `description` / `recommendation`, spinlock `description`, plan-cache `bloat_level`, and CPU-scheduler `pressure_level` / `recommendation` are the Dashboard / reporting-view CASE derivations (not collected columns) — reproduced here so the full result shape is served. Per-second latch/spinlock rates divide by the row's stored `sample_interval_seconds` (the measured seconds the deltas accrued over); they are `null`, never 0, when that interval was unknowable — the collector's first sighting, a counter reset, or a gap past the delta policy, typically a restart. - - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_latch_stats` | Top latch classes by wait time (window totals), with per-second rates + severity / description / recommendation; `severity` is banded from the LATEST interval only and `severity_banded_from` names that interval (delta, seconds, `captured_at`) | `server_name`, `hours_back` (default 24), `top` (default 10), `as_of` | - | `get_spinlock_stats` | Top spinlocks by collisions, with per-second rates + description | `server_name`, `hours_back` (default 24), `top` (default 10), `as_of` | - | `get_resource_semaphore` | Per-semaphore workspace memory vs target/max ceiling: `grants[]` = newest snapshot in the window (`captured_at` / `age_seconds`) AND `window[]` = peak waiters (+ when), peak grant, available floor, summed timeout / forced deltas over EVERY snapshot in the window, per (semaphore, pool) | `server_name`, `hours_back` (default 24), `as_of` | - | `get_memory_grants` | Per-pool grant pressure: `grants[]` = newest snapshot in the window (`captured_at` / `age_seconds`) AND `window[]` = the same peak / floor / summed-delta aggregate per pool | `server_name`, `hours_back` (default 1), `as_of` | - | `get_memory_pressure_events` | RING_BUFFER_RESOURCE_MONITOR memory-pressure notifications (process/system indicator scale 0-3+); not on Azure SQL DB | `server_name`, `hours_back` (default 24), `as_of` | - | `get_plan_cache_bloat` | Plan cache single-use vs multi-use composition + bloat_level classification; `captured_at` / `age_seconds` | `server_name`, `hours_back` (default 24; the span SEARCHED for the newest snapshot), `as_of` | - | `get_cpu_scheduler_pressure` | Latest scheduler snapshot within `hours_back` of `as_of`: runnable queue, worker utilization, pressure_level + warnings; `captured_at` / `age_seconds` | `server_name`, `hours_back` (default 24; the span SEARCHED for the newest snapshot), `as_of` | - | `get_running_jobs` | Currently running SQL Agent jobs with duration vs historical average / p95 | `server_name` | - - ### Trend data-read tools - - Windowed time-series siblings of the core data-read tools — the per-collection / per-second trend for a metric over the window. Same names + parameters Lite and the Dashboard expose; the shape follows Lite where the SKUs diverge. - - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_memory_trend` | Memory usage over time: total / target server memory, buffer pool, plan cache. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back` (default 24), `as_of`, `bucket_minutes` | - | `get_perfmon_trend` | One counter's value + delta over time (summed across instances) | `counter_name` (required), `server_name`, `hours_back` (default 24), `as_of`, `bucket_minutes` | - | `get_file_io_trend` | Read/write latency over time per database and file type, the five with the most I/O stall on their own lines and the rest in one `(other)` line; `database_name` charts one database per file. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back` (default 24), `as_of`, `bucket_minutes`, `database_name` | - | `get_query_trend` | One query's per-collection history (deltas, avg cpu/elapsed, DOP) by query_hash. Carries the same `source` / `effective_start` / `effective_hours_back` / `window_truncated` / `bucket` block as the duration trends below (the window floor is `window_truncated`, spelled `truncated` before #3653) | `query_hash` (required), `database_name` (required), `server_name`, `hours_back` (default 24), `as_of` | - | `get_query_duration_trend` | Overall query elapsed-ms/sec + executions/sec across all queries over time, from the PLAN CACHE. Each point carries `value` (ms/sec), `execution_count` and `executions_per_second` — the last two are the same quantity, and `execution_count` is truncated to an integer, so read `executions_per_second` on a quiet server where the rate is below 1. Each point also carries `elapsed_ms_per_second`, the same quantity as `value` with its unit in the name — read that one — and `peak_elapsed_ms_per_second`, the point's busiest collection. Points are time buckets sized to the window (`bucket` / `bucket_minutes`, `aggregate_note`). Raw `query_stats` rows are dropped at 4 days on a TimescaleDB store, so a window reaching past that is served from the hourly rollup in whole-hour buckets: `source` says which tier answered (`raw` or `hourly`), `effective_start` / `effective_hours_back` where the served series actually begins, and `window_truncated` whether that head sits later than asked. On the hourly tier each hour is its work over 3,600 s, so a partly-collected hour reads low, and the rollup trails the clock by up to two hours. An empty result carries the same block and distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) — and from a window whose head the tier does not hold (`empty`, but the message says the rows were dropped or not yet materialized and that widening cannot help; `--backfill-rollups` is the remedy). On the raw route rates are over each collection's STORED `sample_interval_seconds`; a collection whose interval was unknowable (a restart or counter reset, stored as 0, or the window's first collection when none was stored) is left out of its bucket, never counted as 0 (`unrated_collections`), and a bucket holding nothing else carries null rates (`unrated_points` / `unrated_note`). | `server_name`, `hours_back` (default 24), `as_of`, `bucket_minutes` | - | `get_procedure_duration_trend` | The same series over `procedure_stats`. NOT a duplicate of the above: query_stats attributes a procedure's work to the individual statements inside it, so a procedure that got slower is smeared across however many statements it runs — this charges the whole call to the procedure. Read the two together to tell an ad-hoc regression from a procedure regression. Same tier routing, buckets and disclosure block as `get_query_duration_trend`, over `procedure_stats` / `procedure_stats_hourly` | `server_name`, `hours_back` (default 24), `as_of`, `bucket_minutes` | - | `get_query_store_duration_trend` | The same series over Query Store. The plan-cache trends lose everything an eviction or a restart takes with them; Query Store persists per interval, so this is the series that survives a failover and the one to reach for when a regression is older than the cache. Each interval is counted once, at the hour the work RAN. Its `unavailable` names the cause the other two do not have: Query Store may simply be OFF on every database. Carries the same `source` / `effective_start` / `window_truncated` / `bucket` / `aggregate_note` block as its siblings; where the store has the corrected Query Store rollup, `source` is `rollup+raw` and a `routing` block names the rollup, `raw_from` (the seam: 1-hour buckets before it, per-interval points from it) and, when the window reaches below what the rollup has materialized, `unserved_before` — points before that instant are missing, not zero | `server_name`, `hours_back` (default 24), `as_of` | - - ### System-health parse-on-read tools - - The `get_health_parser_*` family the Dashboard exposes, over Darling's raw `system_health_events`. Where the Dashboard reads its server-side-parsed `collect.HealthParser_*` tables, these shred the raw extended-event XML ON READ with the shared SystemHealthParser and return the same SIGNIFICANT warning set (sp_HealthParser at `@warnings_only = 1`) — the exception is `get_health_parser_system_health`, whose corruption/contention counter series is UNGATED (every snapshot). Each returns the full sp_HealthParser column set per row keyed on the event's `event_time`; the tools window on `event_time` (the event's real time), so "last 24 hours" means events that happened in the last 24 hours. - - Every one of the nine carries a SOURCE WITNESS: `source_observed` (whether this server's system_health session has EVER been read into the store — any event of any type) and `last_captured_at` (the collector's newest capture). Zero rows is four different answers and the read says which: captured in the window and gated out (`empty`, `events_in_window` > 0 — the healthy one), captured before but not in this window (`empty`, `last_captured_of_type_at` says when — widen), never captured for THIS category while the session is being read (`empty`, `source_observed` true — for a rare category such as a memory-node OOM this is the measurement, not a gap), and nothing of any type ever (`unavailable`, `source_observed` false — a dead session or a collector that never ran, and NOT an all-clear). Zero is a measurement only when the source was observed. - - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_health_parser_system_health` | SYSTEM-component snapshots: corruption (bad pages / dumps / access violations) + contention (non-yielding / latch / sick spinlock / CPU) counters | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | - | `get_health_parser_severe_errors` | error_reported severity >= 19 (excl. 17830 / 18056), with database_id resolved to a name | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | - | `get_health_parser_scheduler_issues` | Non-yielding / offline scheduler WARNING rows | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | - | `get_health_parser_memory_conditions` | RESOURCE_MEMPHYSICAL_LOW memory-pressure snapshots | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | - | `get_health_parser_memory_broker` | RESOURCE_MEMPHYSICAL_LOW memory-broker ratio changes | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | - | `get_health_parser_memory_node_oom` | Every recorded per-NUMA-node out-of-memory event (never gated) | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | - | `get_health_parser_cpu_tasks` | QUERY_PROCESSING WARNING rows with pendingTasks >= 10 (worker-thread exhaustion) | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | - | `get_health_parser_io_issues` | IO_SUBSYSTEM WARNING rows (15-second I/O warnings), one per pending-request file | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | - | `get_health_parser_significant_waits` | Individual wait_info events: a real session's non-BACKUP statement waited 500 ms+ on a non-idle wait type. Returns wait type, duration and signal duration, wait resource, session id and the waiting SQL text — `get_wait_stats` gives the instance-wide totals and can never name the statement that paid them. An empty result says WHICH nothing it is: events captured but none significant (the healthy answer), a quiet window (`empty`, widen it), or a server whose wait_info has never been captured (`unavailable`, NOT an all-clear) | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | - - ### Alert + health-overview tools - - The fleet-triage reads the fleet edition previously lacked: what alerts fired and the current alert config, the mute rules in force, and a fast per-server / per-day health verdict. All STORED reads over the monitoring store (no live hit). - - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_alert_history` | Alerts that fired (metric, value vs threshold, delivery success/failure, muted), newest first; omit server_name for the whole fleet, each row names its server. EXCLUDES operator-dismissed alerts by default and says so (`dismissed_excluded`, `dismissed_excluded_count`); pass `include_dismissed` when reconstructing an incident. Page bounded by `limit`; `truncated` and `oldest_returned_alert_time` say what it reached | `server_name` (optional — all servers if omitted), `hours_back` (default 24), `limit` (default 50), `as_of`, `include_dismissed` (default false) | - | `get_alert_settings` | The current alert config the service uses: per-alert enable + thresholds, cooldown, excluded databases, delivery mode, and the scheduled-analysis cadence | none | - | `get_mute_rules` | The alert mute rules in force, so a suppressed server is distinguishable from a healthy-quiet one. An empty result distinguishes no rule ever written from rules that exist but have all lapsed, with the configured count in `hints` | `enabled_only` (default true) | - | `get_notification_routes` | WHERE each alert family is delivered: the closed family taxonomy (`self-monitor`, `reports`, `agent-jobs`, `performance` — every metric name each owns) and the sparse routes layered over the parent channel set. Zero routes = every alert to every configured channel. An empty channel on a route INHERITS the parent's; a filled one replaces it for that family (or enables a channel the parent lacks). Exact metric beats family beats parent, per channel; a recovery routes as its firing. Destinations are secrets and are not reported — `configured_channels` names which channels each route sets | none | - | `get_server_summary` | One-shot per-server health: current CPU %, memory, recent blocking count, recent deadlock count; three clocks named (`cpu_captured_at`, `memory_captured_at`, `last_collection` = newest collection of ANY collector) | `server_name` | - | `get_daily_summary` | A day's composite health band (Healthy / Warning / Critical) plus the signals behind it (waits, deadlocks, blocking, high CPU, memory pressure, alerts). A day before the store's `retention_horizon` is `unavailable` with `data_state: purged` — never a band, because its zeros are absences | `server_name`, `summary_date` (yyyy-MM-dd ONLY, refused otherwise; default today) | - | `get_daily_summary_range` | The SAME rollup across a span of days — one row per collected day, which is the desktop viewer's Performance Calendar month grid. Use it when the question is WHICH day rather than how one day went: scan the bands, then call `get_daily_summary` for the day that stands out. A day with ANY collection appears even when every signal was quiet (Healthy, not missing), so a day absent from the result is a gap in COLLECTION — inside retention. The payload publishes `retention_horizon` (the oldest day every signal table still holds) and `days_before_horizon`; a returned day before the horizon is `data_state: purged` (no signal table holds it) or `past_horizon` (some still do) and `NoData`, NEVER Healthy, because a purged day's per-signal zeros are absences left by the purge while the longer-lived collection log still names the day. Purged and past-horizon rows carry no verdict; `no_run_record` (inside retention, no collector run recorded) keeps its band with a caveat. `as_of` anchors the LAST day of the range, so a past month is `as_of` its last day with `days_back` its length. An empty result distinguishes a range outside this server's history (`empty`) from a server nothing has ever been collected for (`unavailable`) | `server_name`, `days_back` (default 30, max 366), `as_of` | - - **Tuning the alerting (write).** Seven Darling-only tools change the shared alert configuration the service delivers on — the SAME config `get_alert_settings` / `get_mute_rules` read, and the same the Viewer's Settings window writes. They are the only alert writes here; none touches a monitored server or the collected data, and a change hot-reloads into the running service within one collection sweep. - - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `update_alert_settings` | Tunes the alert engine — a PARTIAL update of the single global settings row. **Read-modify-write**: call `get_alert_settings` FIRST, change only the fields you want, and pass THOSE back in the same nested shape (e.g. `{"cpu":{"threshold_percent":90},"cooldown_minutes":10}`); everything you omit is left unchanged. Each field is validated (thresholds in range, `cpu.mode` `sql`/`total`, `delivery.mode` `Summary`/`PerEvent`); a bad value or an unknown field returns `{status:"invalid"}` and writes nothing. SMTP/webhook credentials cannot be set here | `settings_json` (required — the partial settings object) | - | `create_mute_rule` | Adds a mute rule that suppresses matching alerts (still logged, just not delivered). Any combination of scope/pattern fields; NO fields set = mute every alert (a whole-fleet silence). Returns the stored rule with its generated id | `server_name`, `metric_name`, `database_pattern`, `query_text_pattern`, `wait_type_pattern`, `job_name_pattern`, `reason`, `expires_at` (all optional) | - | `update_mute_rule` | Edits an existing mute rule IN PLACE — a PARTIAL update: pass ONLY the fields to change in the shape `get_mute_rules` returns, and an explicit JSON `null` clears a field (`{"expires_at_utc":null}` makes a rule permanent again). `created_at_utc` NEVER moves, so the `Stale Mute Rules` self-alert still ages the rule from when it was authored — where a delete-and-re-create resets that clock. `enabled` is not editable here (use `set_mute_rule_enabled`). An edited `server_name` must match the alert rows' spelling exactly (copy it from `get_alert_history`) or the rule goes inert. Returns `{status:"updated", updated_fields:[...]}` with the rule as stored, `{status:"unchanged"}`, `{status:"not_found"}`, or `{status:"invalid"}` | `rule_id` (required), `changes_json` (required — the partial rule object) | - | `delete_mute_rule` | Deletes a mute rule by id (from `get_mute_rules` / `create_mute_rule`); returns `{status:"deleted"}` or `{status:"not_found"}` | `rule_id` (required) | - | `set_mute_rule_enabled` | Enables or disables a mute rule by id WITHOUT deleting it — the reversible form of mute/un-mute. The rule keeps its id, scope, reason and `created_at_utc`, so a temporary change does not reset the age the `Stale Mute Rules` self-alert reports, and there is no window between a delete and a re-create in which the suppressed alerts deliver. Returns `{status:"updated"}`, `{status:"unchanged"}` when it already holds that value, or `{status:"not_found"}` | `rule_id` (required), `enabled` (required) | - | `set_notification_route_enabled` | Enables or disables a notification route by `route_id` WITHOUT deleting it — the alerts it matched fall to the next level (family route, then the parent channels) on the next firing. Returns `{status:"updated"}` with the route as stored, `{status:"unchanged"}`, or `{status:"not_found"}`. Cannot author or re-point a route (destinations are secrets; use the Viewer's Settings window) | `route_id` (required), `enabled` (required) | - | `delete_notification_route` | Deletes a notification route by `route_id`; the alerts it matched fall back to the family route, then the parent channels. Prefer `set_notification_route_enabled` for a temporary change. Returns `{status:"deleted"}` or `{status:"not_found"}` | `route_id` (required) | - - Because `update_alert_settings` only touches the fields you send, always `get_alert_settings` FIRST to read the current values, change what you need, then send just those back — never guess the full row. The same read-modify-write discipline applies to `update_mute_rule` against `get_mute_rules`. - - ### Cross-server reads - - The reads a central store makes possible and a single-server edition cannot answer at all. All are STORED reads, and each is also served to the web dashboard (`/api/fleet`, `/api/ag` and `/api/sweeps`) through the SAME readers and builders — so an agent and a browser see identical numbers. - - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_sweep_reports` | The scheduled Fleet Sweep Reports — the stateful whole-fleet summaries persisted at the configured cadence (`get_alert_settings`' `fleet_sweep` group; default hourly): the sweep timeline over the window with each sweep's document embedded, the newest sweep in full (per-server verdicts with band transitions pre-computed, the would-have-paged ledger whenever the sweep ran under the alert master switch), and the watch-item worklist with its hysteresis position. Pass `sweep_id` for one sweep in full instead | `hours_back` (default 1), `as_of`, `sweep_id` (a STRING — the ids do not survive a JSON number round trip), `watch_state` (`pending`/`open`/`carried`/`closed`; omit for open + carried) | - | `get_fleet_overview` | Every enabled server's pre-banded health card (CPU, memory, blocking, deadlocks, worker threads, collector health) plus the fleet rollup — band counts, cross-server blocking / deadlock totals, and the worst-first "needs attention" ranking. Use it FIRST to decide which server to drill into. `collection_health_age_seconds` says how old the collector-health half is: that half is one 7-day scan shared by every overview call of the same minute, whatever `hours_back` was — so call once with the widest window rather than racing several | `hours_back` (default 1) | - | `get_ag_health` | Always On Availability Group topology from the latest collection per server: each AG's replicas (role, connected / operational state, synchronization health, availability + failover mode, endpoint) and its per-database secondary state (synchronization state, log-send / redo queue KB, send / redo rate KB/s, estimated drain minutes, secondary lag seconds, suspended + reason) | `server_name` (optional — the whole fleet if omitted) | - - `get_ag_health` returns ONE GROUP PER (reporting server, AG). Every replica of an AG reports the whole AG's replica set, so an AG whose replicas are all monitored appears once per monitored replica, each group naming the `server_name` whose view it is. They are deliberately not merged: `operational_state` and `recovery_health` are populated only for the LOCAL replica, `connected_state` is meaningful only from the primary, and under WSFC quorum loss an instance answers from cached metadata — so the perspectives genuinely differ, and comparing them is how you spot a split view. A column the DMV does not populate reads null and bands `Unknown`, which never counts against a group's severity. - - Two derived fields are computed by the reader, not collected: `est_send_drain_minutes` and `est_redo_completion_minutes` are queue ÷ rate, null when a non-empty queue is moving at zero. Read `secondary_lag_seconds` together with `is_suspended` — the DMV reports lag as 0 (not null) while data movement is suspended, so a suspended replica looks perfectly caught up on lag alone. Severities restate the DMVs' own state / health verdicts and nothing else — lag and queue depth are NOT banded, so a badly lagging asynchronous secondary can sit under a healthy severity; read the numbers. Each group's `collection_time` is that server's newest AG collection, and because the collectors write no row for a server with no AGs, a server whose AGs were dropped keeps returning its last non-empty snapshot — an old `collection_time` is that case. An empty result means no monitored server hosts an AG; the collectors write no rows for an instance without them, and they do not run against Azure SQL Database. - - `get_sweep_reports` carries two field-level contracts an agent must read rather than infer. MUTE SEMANTICS: `alerts_enabled` on a sweep states the alert master switch AS THE SWEEP RAN — `false` means DELIVERY WAS OFF for that window: nothing paged, however bad the hour was, and the sweep's `would_have_paged` ledger is what its own scoring banded Critical in the silence (present-and-empty = "muted, and nothing would have paged", a statement; the key is ABSENT on an alerts-on sweep because no such check was made). QUIET IS NOT CLEAN: `instruments_alive: false` means the sweep could not prove its own data sources — a dead reader, a silent fleet outside the post-restart settle window, or a frozen alert-pass counter beside a delivering path — so read that sweep's `instrument_liveness` block before believing any quiet card it carries; an empty window there is unreadable, not healthy. Channel delivery of sweep content is bounded at ONE daily rollup (INFO, through the alert channels, master-gated); this tool and the web feed are the full-cadence record. - - ### Store self-metrics - - `get_store_metrics` reads the MONITORING STORE's own size and growth series — not a monitored SQL Server's — from the hourly self-metrics snapshot the service records: every hypertable and continuous aggregate (sized through its materialization), the payload dimensions, the named plain tables, two catch-alls (`other`, `system`), the whole store with the enabled-server count, and each TimescaleDB background job. It answers SUMMARY FIRST: the store-level blocks (`inventory` reconciled against `pg_database_size` with a stated coverage, `retention` holds, `job_history`, `checkpointer`), the whole-store daily growth with the per-server ingest rate (daily growth ÷ enabled servers — the number to multiply when onboarding N servers), and three ranked lists bounded by `limit`: the largest objects, the fastest-growing, and the background jobs (failing first, then closest to their cadence). Each row carries its change over the window in place of a per-object series; `object_kind` lists every object of one kind, and an exact `object_name` returns that object's daily series. Each daily point is that day's LAST snapshot, not its maximum, so a maximum question needs `timescaledb_information.job_history`; the tool's description and its `job_history` block say when that view records and whom it shows rows to. 400 days of history; no `server_name` parameter, because the store is the subject. - - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_store_metrics` | The store's own size and growth: a summary (the reconciled inventory, whole-store daily growth with the per-server ingest rate, the largest and fastest-growing objects, the jobs failing or closest to their cadence), one kind's objects, or one object's daily series | `days_back` (default 30, max 400), `object_kind`, `object_name`, `limit` (default 10) | - | `get_store_log` | Per-class census of the monitoring store's OWN server log, with the capture denominator, the per-hour median beside each class's count, the retained-message detail, and the classes this build has that the window did NOT see | `hours_back` (default 24, max 168), `limit` (default 50), `as_of` | - | `get_store_query_stats` | The monitoring store's OWN SQL statements ranked by cumulative server-side cost (pg_stat_statements), each attributed to the role that ran it (viewer = the web viewer, remote read-only seats and custom-alert evaluation, mcp = MCP tools, admin = the local Darling Viewer, owner = the service, and a bring-your-own store's web viewer and MCP without logins of their own), with each role's share of the recorded time; `precondition` with the remedy when pg_stat_statements is missing, not loaded, too old or not granted | `role`, `order_by` (default `total_time`), `top` (default 20), `full_text` (default false) | - | `get_collector_stall_probes` | The out-of-band server-wide wait samples taken while one of OUR collectors was stalled mid-read: the client-side trigger evidence beside the instance's whole wait list and scheduler load, with the outcome census (including the probes that could not connect) always beside them. Unbanded. | `server_name` (optional; omit for the fleet), `days_back` (default 7, max 60), `limit` (default 50) | - | `get_oversized_plan_backlog` | The cached plans measured as too large to capture inline, and what the out-of-band sweep has done about each: per server the three verdict buckets (pending/captured/expired, a strict partition of the total), the attempt figures on still-pending rows, the newest capture and expiry instants, and `observed_bytes` min/median/max, with the per-collector census beside them. `last_captured_at` is how a working fetch half is told from one that has never succeeded. No time window — a worklist updated in place, not a series. | `server_name` (optional; omit for the fleet), `include_rows` (default false; needs `server_name`), `limit` (default 50) | - - `get_store_log` reads what the MONITORING STORE's own PostgreSQL server log recorded — the store's runtime complaints, as opposed to `get_store_metrics`' size and jobs. It is the surface for the store's half of a client-side symptom: an `Exception while reading from stream` on the service side has a paired `ERROR: canceling statement due to user request` on the store side, and connections lost mid-statement cluster inside a continuous-aggregate refresh convoy rather than spreading across it. - - The service reads its own store's log on the hourly self-metrics tick and stores a per-class CENSUS rather than the lines, and the reason is the measured distribution: a production day of that log holds ~1,100 user-request cancels (the store's rendering of a client-side `CommandTimeout`, expected and not a fault), ~190 administrator terminations of background workers from job churn, and ~170–290 Windows shared-memory-reservation retries that PostgreSQL retries itself. Those are the FLOOR, so they come back as named counts with no text — the exclusion is a number you can see rather than a filter you cannot. The classes that keep their text are the ones worth reading one at a time: crash recovery, data-integrity and disk complaints, background-worker slot exhaustion (the silent stopper for compression and CAGG jobs), the store deadlocking against itself, connections lost mid-statement, the store's own statement and lock timeouts, statements that ran past the store's slow-statement line, panics, and anything at WARNING or worse that no rule recognises — that last one is the safety net, so a shape nobody anticipated is visible rather than filtered. - - Read the census by comparing each class's `occurrences_last_hour` against the `per_hour_median` beside it, which is computed over EVERY captured hour including the ones the class did not appear in. There is deliberately NO health band: a quiet store with a large cancel floor is healthy and has to keep reading that way. `capture_summary` is the denominator — captures landed against captures expected, bytes read, bytes still unread, and how many captures discarded a resume marker because the weekday log ring truncated the file — and it is what separates "the store said nothing" from "nobody read the log". `classes_not_seen` names the classes this build has that the window did not see, so a class missing from the census is legible as a zero rather than as a class this build might not have. `sample_line` on a retained event is the entry, timestamp and all. Messages are shown as PostgreSQL wrote them; SQL text is normalized with literals replaced by `?`, and a statement that cannot be read to its end is withheld; nothing here parses `log_line_prefix` or the log's clock. Messages differing only in a quoted value or a number count as one row, `message_text` showing one. `slow_statement`'s message is the statement alone, comments stripped and bind-parameter DETAIL lines dropped, so each slow query is one row. No `server_name` parameter, because the store is the subject. 400 days of history. - - ### Default Trace events - - `get_default_trace_events` returns the SIGNIFICANT server events the built-in Default Trace captures (stored, read-only, windowed on `event_time`, which this tool returns in UTC; the Default Trace stores its StartTime in the monitored server's local clock and this read de-skews it, so these events correlate directly against `collection_time` and `last_collection` with no offset arithmetic) — data/log file auto-grow/shrink STALLS (over 1 second), severe ErrorLog writes (severity >= 16), schema DDL (object create/alter/delete), security audits (audit-change / DBCC / alter-trace), and Server Memory Change — each tagged with a `category`. It is the same significant set the viewer's System Events surface shows (the collector curates the event set server-side; the tool adds only the ErrorLog severity floor). Configuration-change events are intentionally excluded to avoid double-counting the config-snapshot diff — use `get_server_config_changes` / `get_database_config_changes` / `get_trace_flag_changes` for those. Not available on Azure SQL Database (no default trace there). - - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_default_trace_events` | Significant Default Trace events — auto-grow/shrink stalls, severe ErrorLog, schema DDL, security audits, memory change — each categorized (config-change events excluded) | `server_name`, `hours_back` (default 24), `limit` (default 100), `as_of` | - - ### Custom Views (create & manage) - - Custom Views are saved dashboards and notebooks a user composes from a curated catalog of measures and dimensions (the same views the web viewer's editor builds). The create/update/delete tools here are the only view-authoring WRITE surface on this server — they store view definitions in the monitoring store's `config.custom_views` table; none of these tools touches a monitored SQL Server or the collected performance data. A view definition is either a dashboard (`{"panels":[...]}`) or a notebook (`{"kind":"notebook","cells":[...]}`); a composed panel names a catalog `source` + `measure` (or `ratio`), an `aggregate`, an optional `timeBucket` (time series), `topN` (ranked), or both together (the bucketed trend of the top-N groups by window total; optional `includeOther` folds the remainder into one `(other)` series), `filters`, `groupBy`, a `unit`, and a `viz`. **Call `describe_custom_view_catalog` FIRST** to get the exact legal `source`/`measure`/`ratio`/dimension/`aggregate`/`unit`/`viz` names: the compiler accepts ONLY catalog identifiers, and the validation errors do not enumerate the legal names, so composing from the catalog is far faster and more reliable than guessing. Then `validate_custom_view` (and iterate until valid) before `create_custom_view` / `update_custom_view` — create/update run the same validation and reject an invalid definition. Use `run_custom_view_panel` to see the actual data a composed panel produces so a generated view can be checked end-to-end. - - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `describe_custom_view_catalog` | The compose vocabulary — measures (each with its source, kind, valid aggregates, allowed dimensions, units, and per-server-type availability), dimensions, unit families, aggregates, time buckets, filter ops, and viz types. Call FIRST when authoring a view so panels use only legal identifiers | none | - | `list_custom_views` | Lists every saved view (dashboards + notebooks) as summaries — id, name, description, kind, version | none | - | `get_custom_view` | Gets one view in full, including its definition JSON and current version | `view_id` (required) | - | `validate_custom_view` | Dry-run: validates a definition against the catalog + composer rules WITHOUT saving; returns `{valid, error}` | `definition` (required) | - | `create_custom_view` | Validates then saves a new view (returns the stored view at version 1); conflict on a duplicate name | `name` (required), `definition` (required), `description` | - | `update_custom_view` | Validates then updates a view in place under optimistic concurrency (pass the `version` you read); conflict on a stale version or duplicate name. `description` follows the shared write vocabulary: omitted = unchanged, empty string = cleared | `view_id` (required), `name` (required), `definition` (required), `version` (required), `description` | - | `delete_custom_view` | Deletes a view by id (permanent) | `view_id` (required) | - | `run_custom_view_panel` | Compiles + runs a single composed panel and returns `{sql, rows, annotations}` — the composer's live preview, for checking a panel's data before saving | `spec` (required — a JSON object `{panel, variables?, values?, server?, hours?}`) | - - ### Server onboarding (add & remove) - - Two write tools stand up or tear down FLEET monitoring conversationally — the service-side twin of the WPF viewer's Add / Manage Servers dialogs. They write ONLY the monitoring store's monitored-server registry (`config.config_monitored_servers`); neither runs anything on a monitored SQL Server beyond a one-time connection probe, and neither touches the collected performance data. A change is picked up by the running service within one collection sweep (no restart). - - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `add_servers` | BULK-adds monitored servers: pass a JSON ARRAY of server objects and each is validated, connection-tested IN the service, and (if new and reachable) saved. Per object: `host` (required), `display_name`, `database` (one DB only, e.g. an Azure SQL Database), `engine` (`sqlserver` default, or `postgres`), `auth` (`Windows`/`SQL`, or the two NON-INTERACTIVE Microsoft Entra modes `ServicePrincipal` / `ManagedIdentity` — default `Windows`; a PostgreSQL target requires `SQL`), `username`+`password` (required for `SQL`; for `ServicePrincipal` they are the Entra application/client id + client secret; for `ManagedIdentity` `username` is an optional user-assigned identity's client id and there is no password), `port` (PostgreSQL only), `encrypt_mode` (`Optional`/`Mandatory`/`Strict`, default `Mandatory`), `trust_server_certificate` (default false), `read_only_intent`, `multi_subnet_failover`. Servers are processed in order; a duplicate (case-folded, vs existing or an earlier entry) is `duplicate`, an unreachable server is `connection_failed` (the batch continues), a server whose probed connection lands in a database another registration already covers is `collides` (not added), and the INTERACTIVE Entra modes (MFA / device-code / default-credential) are `invalid` — they cannot run headless. Returns `{requested, added, skipped, collided, failed, results:[{server, status, detail}]}`; the four counters SUM to `requested` (`added`→added, `duplicate`→skipped, `collides`→collided, `connection_failed`/`invalid`→failed), and only `added` servers are monitored | `servers_json` (required — a JSON array of server objects) | - | `remove_server` | Removes a monitored server by name — exact match on the storage or display name, or a partial match ONLY when it is unique. Resolves against the store's monitored-server DEFINITIONS (the table `add_servers` writes), not the connected-servers registry, so a server that was added but never connected can be removed. An ambiguous name (a fragment several servers contain, or a display name two definitions share) deletes NOTHING and returns `{status:"ambiguous", candidates:[{server, display_name, ever_connected}]}`; re-issue with one candidate's full name. Returns `{status:"removed", server, display_name, matched_by, matched_in, ever_connected}`, `{status:"ambiguous", ...}` or `{status:"not_found"}` (`matched_in:"servers"` on a not_found = the name is a connected server with no store definition, i.e. defined in darling.json). Already-collected history is NOT deleted | `server_name` (required) | + Every tool below that takes `hours_back` also takes `as_of`: an ISO-8601 UTC instant that moves the window's END off "now"; `hours_back` stays the LENGTH. Reach for it whenever the question is about a time rather than about the present, and do NOT substitute a wider `hours_back`: for an aggregate read a wider window is a DIFFERENT answer, not the same answer with more rows. - A SQL password is encrypted at rest (DPAPI, the service identity) and is NEVER returned by a read tool. It DOES travel to this endpoint inside `add_servers`' request JSON, so on a LAN deployment reach the endpoint only through the documented TLS reverse proxy. + - Accepted: full ISO-8601 with an offset, or a bare `2026-08-19` (midnight UTC). An unparseable or future `as_of` is REFUSED, not read as "now"; an older one returns the normal `empty` / `unavailable`. + - Latest-snapshot reads take no `as_of` — read `captured_at` / `age_seconds` instead. `get_pvs_stats`, `get_fleet_overview`, `get_store_metrics` never take it. + - The analysis family DOES take it: `get_analysis_facts`/`analyze_server` re-run fact collection, anomaly detection and scoring over the anchored window (`analyze_server` with `as_of` is EXPLORATORY, does not persist). - The three config-change tools diff the store's config snapshots. This edition captures configuration WHEN THE SERVICE CONNECTS to a server (not on a fixed schedule), so a change is detected between two connect snapshots and at least two are needed — a stable, always-connected deployment may show no changes until the next connect. They emit only the values the collectors capture; the Dashboard's `requires_restart` / setting `description` / `setting_type` / generated change-narrative enrichment is not collected here and is omitted. `get_blocking_deadlock_stats` (the Dashboard's blocking/deadlock aggregate) is NOT hosted: this edition has no blocking/deadlock rollup table — use `get_blocking` / `get_deadlocks` for the raw events. + Each finding's `confidence` is an EVIDENCE score (0.20 for the symptom alone, more as the root fact's amplifier checks match and the chain deepens — a lone uncorroborated symptom is 0.20, never 1.0) and `confidence_basis` says in words what it rests on (an ANOMALY_* fact's `baseline_confidence` is the baseline's trustworthiness, not a finding's `confidence`). - Jumping from an alert to its incident: `get_blocking`, `get_deadlocks` and `get_deadlock_detail` accept an - optional `dedup_key` — the #1140 alert fingerprint, shown to operators as the alert's **Dedup Key** fact and - carried onto downstream tickets. Pass it to get exactly that incident instead of pulling a server+time window - and guessing which row the alert meant. Those three tools also RETURN a `dedup_key` on every row, so an - incident found by browsing can be correlated back to its alerts, or handed to another agent as a stable - identifier. Two things to know when it matches nothing: the fingerprint is scoped to the server's DISPLAY name - and to the incident's involved objects, so a server renamed since the alert fired has different keys now; and - `hours_back` still bounds the search, so widen it before concluding the incident is gone. The no-match response - says how many rows it examined, which distinguishes those cases. The fingerprint scan runs over the window - BEFORE `limit` (so the cap can never discard the incident the key names), up to a stated ceiling: a match - payload carries `rows_examined` and `scan_truncated`, and a no-match answer on a window the scan could not - finish says so and asks for `as_of` anchored at the alert time with a narrow `hours_back`. + | Tool | Note | + |------|------| + | `get_query_store_health` | Per-database Query Store health (latest hourly snapshot) — actual vs desired state, readonly_reason decoded, storage vs cap, cleanup thresholds, and the two capture modes (`query_capture_mode` ALL / AUTO / CUSTOM / NONE — ALL is the plan-churn factory; `wait_stats_capture_mode` ON / OFF; null = pre-rung row or pre-2017 engine) | - Note on `next_tools`: analyze_server findings include `next_tools` recommendations. Most are hosted on this server — the plan-analysis tools (`analyze_query_plan`, `analyze_query_store_plan`) and the data-read tools listed above (`get_wait_stats`, `get_top_queries_by_cpu`, `get_cpu_utilization`, `get_memory_stats`, `get_file_io_stats`, `get_tempdb_trend`, `get_blocking`, `get_deadlocks`, `get_waiting_tasks`, `get_active_queries`, ...) — so follow those here. `get_top_queries_by_cpu` / `get_top_procedures_by_cpu` / `get_query_store_top` are where the `query_hash` / `sql_handle` / `query_id` + `plan_id` keys for the plan-analysis tools come from. The resource-contention + jobs tools (`get_latch_stats`, `get_spinlock_stats`, `get_resource_semaphore`, `get_memory_grants`, `get_plan_cache_bloat`, `get_cpu_scheduler_pressure`, `get_running_jobs`), the trend siblings (`get_memory_trend`, `get_perfmon_trend`, `get_file_io_trend`, `get_query_trend`, `get_query_duration_trend`), the `get_health_parser_*` system-health family, and the blocking/deadlock trend + memory-pressure reads (`get_blocking_trend`, `get_deadlock_trend`, `get_memory_pressure_events`) are all hosted here too — follow those `next_tools` on this server. Two `next_tools` names differ from what this edition hosts: `get_blocked_process_reports` (a Lite name) is served here as `get_blocked_process_xml` (with `get_blocking` for a quick overview), and `get_blocking_deadlock_stats` (the Dashboard's blocking/deadlock rollup) is not hosted at all — use `get_blocking` / `get_deadlocks` instead. + `update_alert_settings`/`update_mute_rule` are PARTIAL updates: read `get_alert_settings`/`get_mute_rules` FIRST, send back only changed fields. For Custom Views, call `describe_custom_view_catalog` FIRST, then `validate_custom_view` before `create_custom_view`/`update_custom_view`. - ## Recommended Workflow + ## Workflow - 1. **Discover**: `list_servers` — see the monitored servers and their collection freshness; `get_collection_health` confirms the collectors are current before you trust the data - 2. **Diagnose**: `analyze_server` — run the inference engine for an evidence-backed assessment with severity-ranked findings, each carrying `next_tools` - 3. **Review history**: `get_analysis_findings` — see what the service's scheduled analysis has already found - 4. **Investigate the metrics**: follow a finding's `next_tools` into the data tools — `get_cpu_utilization`, `get_wait_stats` / `get_wait_trend`, `get_memory_stats` / `get_memory_clerks`, `get_file_io_stats`, `get_tempdb_trend`, `get_perfmon_stats` - 5. **Find the query**: `get_top_queries_by_cpu` / `get_top_procedures_by_cpu` / `get_query_store_top` — identify the expensive query/procedure and get its `query_hash` / `sql_handle` / `query_id` + `plan_id` - 6. **Analyze its plan**: `analyze_query_plan` / `analyze_procedure_plan` / `analyze_query_store_plan` — analyze the captured plan for warnings, missing indexes, and grant/spill problems (or `analyze_plan_xml` for plan XML you already have) - 7. **Deep dive / compare / config**: `get_analysis_facts` (what the engine sees), `compare_analysis` (new vs baseline), `audit_config` (SQL Server config, or a PostgreSQL target's `CONFIG_PG_*` settings projected into the same shape) - 8. **Silence noise**: `mute_analysis_finding` — mute a finding pattern the operator has accepted + `list_servers` -> `get_collection_health` -> `analyze_server` (follow each finding's `next_tools`) -> `compare_analysis`/`audit_config` -> `mute_analysis_finding` to silence an accepted pattern. """; } diff --git a/Lite.Tests/CompareAnalysisDispersionTests.cs b/Lite.Tests/CompareAnalysisDispersionTests.cs index 54c26da5be..3cd50053ec 100644 --- a/Lite.Tests/CompareAnalysisDispersionTests.cs +++ b/Lite.Tests/CompareAnalysisDispersionTests.cs @@ -185,11 +185,12 @@ public async Task AKeyAppearingAtATrace_IsNotANewIssue_ButOneRegisteringOnItsLad } /// - /// The tool's description and the instructions row promise what the payload now carries — and say what - /// "worse" does not mean. A caller reads these before deciding to trust a verdict. + /// The tool's description promises what the payload now carries — and says what "worse" does not mean. A + /// caller reads this before deciding to trust a verdict. #3898 Phase 2 (D5, D6) retired the instructions + /// row that used to duplicate this on both SKUs; the description is now the only surface. /// [Fact] - public void Description_AndInstructionsRow_SayWhatWorseMeans_AndWhatItDoesNot() + public void Description_SaysWhatWorseMeans_AndWhatItDoesNot() { var method = typeof(McpAnalysisTools).GetMethods(BindingFlags.Public | BindingFlags.Static) .Single(m => m.GetCustomAttribute()?.Name == "compare_analysis"); @@ -199,12 +200,6 @@ public void Description_AndInstructionsRow_SayWhatWorseMeans_AndWhatItDoesNot() { Assert.Contains(token, description, StringComparison.Ordinal); } - - var row = McpInstructions.Text.Split('\n').Single(l => l.Contains("| `compare_analysis` |", StringComparison.Ordinal)); - foreach (var token in new[] { "`band_source`", "`families`", "`plan_cache_churn`", "N=1 vs N=1" }) - { - Assert.Contains(token, row, StringComparison.Ordinal); - } } /* ── helpers ── */ diff --git a/Lite.Tests/McpDescriptionTruthPinTests.cs b/Lite.Tests/McpDescriptionTruthPinTests.cs index 774c6231c9..ec99a691dc 100644 --- a/Lite.Tests/McpDescriptionTruthPinTests.cs +++ b/Lite.Tests/McpDescriptionTruthPinTests.cs @@ -61,7 +61,9 @@ public void AuditConfig_DoesNotClaimEditionAwareness_AndTheBodyHasNoEditionBranc /// longer built inline per SKU (where two independent copies could drift, the #3696 lie told twice) — both /// tool bodies build their envelope through the shared TrendPayloads.CpuUtilization, whose /// CpuCadenceNote constant is now the ONE place the sentence is spelled. This pins that constant's text, - /// and that each SKU's tool body actually reaches it rather than composing its own. + /// and that each SKU's tool body actually reaches it rather than composing its own. (#3898 Phase 2 briefly + /// rewrote this to expect a per-file inline literal; #3960's shared-constant refactor was never undone, so + /// that rewrite failed against both SKUs — reverted back to reading the one shared constant.) /// [Theory] [InlineData("Lite/Mcp/McpCpuTools.cs")] @@ -123,16 +125,8 @@ public void PlanTools_DescribeTheStatedOperatorCut_AndTheCreateStatement() Assert.DoesNotContain("a hint, not a design", description, StringComparison.Ordinal); } - /* The instructions' anti-pattern bullet names the field too, and neither the pre-#3696 wording ("CREATE - statements and impact scores" — an unlabelled impact) nor #3696's suppression ("evidence, not DDL") is - what it says now. */ - var instructions = McpInstructions.Text; - Assert.Contains("suggested `create_statement` — corroboration for a statement already measured slow, never a diagnosis", instructions, StringComparison.Ordinal); - Assert.Contains("every row carries the fixed `caveat`", instructions, StringComparison.Ordinal); - Assert.Contains("impact estimate (`impact_basis`)", instructions, StringComparison.Ordinal); - Assert.DoesNotContain("Missing indexes with CREATE statements", instructions, StringComparison.Ordinal); - Assert.DoesNotContain("evidence, not DDL", instructions, StringComparison.Ordinal); - Assert.DoesNotContain("no CREATE INDEX text", instructions, StringComparison.Ordinal); + /* #3898 Phase 2 (D5, D6): the instructions' anti-pattern bullet that used to restate this is gone on + both SKUs — the descriptions checked above are now the only surface. */ } /* ---------------- plumbing ---------------- */ diff --git a/Lite.Tests/McpInstructionsBudgetTests.cs b/Lite.Tests/McpInstructionsBudgetTests.cs new file mode 100644 index 0000000000..1fd04022fe --- /dev/null +++ b/Lite.Tests/McpInstructionsBudgetTests.cs @@ -0,0 +1,43 @@ +/* + * 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 PerformanceMonitorLite.Mcp; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// #3898 Phase 2 (D2, D5, D6): Lite moves in lockstep with Darling's instructions rewrite. See +/// DarlingMcpInstructionsBudgetTests for the full rationale — the per-tool "Tool Reference" tables and +/// prose are gone because nothing enforced they stayed in sync with the tool descriptions that are the real +/// interface. A red-watch confirmed this fails against the pre-#3898-Phase-2 instructions (52,942 characters, +/// with the old per-family table headings present). +/// +public sealed class McpInstructionsBudgetTests +{ + private const int InstructionsBudget = 8_000; + + [Fact] + public void Instructions_AreWithinTheD2Budget() + { + var length = McpInstructions.Text.Length; + Assert.True(length <= InstructionsBudget, + $"McpInstructions.Text is {length} characters; D2's budget is {InstructionsBudget}"); + } + + [Fact] + public void Instructions_CarryNoPerFamilyToolReferenceTable() + { + var text = McpInstructions.Text; + Assert.DoesNotContain("### Wait Statistics Tools", text, StringComparison.Ordinal); + Assert.DoesNotContain("### Query Performance Tools", text, StringComparison.Ordinal); + Assert.DoesNotContain("### Blocking & Deadlock Tools", text, StringComparison.Ordinal); + Assert.DoesNotContain("| Tool | Purpose | Key Parameters |", text, StringComparison.Ordinal); + } +} diff --git a/Lite/Mcp/McpInstructions.cs b/Lite/Mcp/McpInstructions.cs index 361a866bdb..a8e4b37121 100644 --- a/Lite/Mcp/McpInstructions.cs +++ b/Lite/Mcp/McpInstructions.cs @@ -2,7 +2,12 @@ namespace PerformanceMonitorLite.Mcp; /// /// Server instructions sent to MCP clients during initialization. -/// Provides context about tool usage, data characteristics, and diagnostic workflows. +/// #3898 Phase 2 (D5, D6): moves in lockstep with Darling's rewrite. The per-tool "Tool Reference" tables +/// and their prose are gone — a tool's own description in tools/list is the authoritative surface for +/// what it does and what it takes. What stays is what no single tool's own description can say by itself: +/// cross-tool contracts (the status-word vocabulary, the as_of/hours_back anchor rule) and +/// specific traps proven to cause a misreading (memory-pressure indicator interpretation, the Query Store +/// capture-mode footgun). /// internal static class McpInstructions { @@ -11,26 +16,15 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo ## CRITICAL: Read-Only Access - This MCP server provides STRICTLY READ-ONLY access to previously collected performance data. You CANNOT: - - Execute arbitrary SQL queries against any server - - Kill sessions, processes, or connections - - Change any server configuration or settings - - Modify, insert, or delete any data - - Run any ad-hoc diagnostics beyond what the collectors have already captured - - If a user asks "what's locking table X right now?" or "run this query," you can only answer from what the collectors have already captured. You cannot run live queries. Be upfront about this limitation. + This MCP server provides STRICTLY READ-ONLY access to previously collected performance data. You CANNOT execute arbitrary SQL, kill sessions/connections, change server configuration, modify or delete any data, or run ad-hoc diagnostics beyond what the collectors have already captured. If asked to run a live query or check what's locking a table right now, answer only from what has already been collected and say so. ## How Data Is Collected - Performance Monitor Lite collects data from remote SQL Server instances and stores it locally in DuckDB/Parquet files. Data is collected in snapshots at regular intervals (typically every 1-15 minutes depending on the collector). This means: + Performance Monitor Lite collects into local DuckDB/Parquet, every 1-15 minutes depending on the collector. Data is only as fresh as the last collection cycle. Delta-based collectors (procedure stats, perfmon counters) need two cycles before producing non-zero values — a newly added server shows empty procedure stats for ~30 minutes, and `execution_count` 0 beside non-zero CPU/elapsed is that same delta artifact, not an error. `server_name` is used automatically when only one server is configured. - - Data is only as fresh as the last collection cycle. If a collector last ran 10 minutes ago, you're seeing 10-minute-old data. - - Delta-based collectors (stored procedures, perfmon counters) require at least two collection cycles before producing non-zero values. A newly added server will show empty procedure stats for the first ~30 minutes. - - Wait stats represent cumulative or delta values since the last collection, not instantaneous snapshots. - - All tools accept a `server_name` parameter. If only one server is configured, it's used automatically. - - When `execution_count` is 0 but CPU/elapsed time is non-zero, this is a delta calculation artifact — the query was in the plan cache at both collection points but was not executed between them. This is normal and can be ignored. + ## Tools - ## Tool Reference + 88 tools: discovery/health, waits, CPU, contention, plan cache, query performance, blocking/deadlocks, memory, I/O, tempdb, storage/index, perfmon, alerts, jobs, configuration, system-health parse-on-read + Default Trace, server info, sessions/active queries, and execution-plan + diagnostic analysis. Tool names and parameters are self-describing in each tool's own description. ### Reading an empty result @@ -40,302 +34,28 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo ### Asking about a PAST window - Every tool below that takes `hours_back` also takes `as_of`: an optional ISO-8601 UTC instant that moves the END of the window off "now". `hours_back` stays the window's LENGTH. So the four hours around last Tuesday 03:00 is `as_of=2026-08-19T05:00:00Z, hours_back=4` — not `hours_back=170`. - - Reach for it whenever the question is about a time rather than about the present, and do NOT substitute a wider `hours_back`: for an aggregate read a wider window is a DIFFERENT answer, not the same answer with more rows. It changes what a top-N returns, what an average is taken over, and how much a capped read truncates. - - - Accepted forms: `2026-08-19T05:00:00Z`, `2026-08-19T05:00:00` (read as UTC), `2026-08-19T07:00:00+02:00`, `2026-08-19` (midnight UTC). - - An unparseable `as_of`, or one in the future, is REFUSED with a message rather than quietly answered as "now" — a read that silently reverts to now is indistinguishable from a correct one. - - An `as_of` older than anything the store still holds is NOT refused. It returns the read's normal `empty` / `unavailable` status, which means exactly what it says: we looked in the window you named and there was nothing in it. - - Tools that take no window at all (latest-snapshot reads like `get_memory_stats`, `get_file_io_stats`, `get_index_usage`, and the configuration reads) do not take `as_of` — they read the newest row, and there is no window to move. - - **Latest is a time.** Every latest-snapshot read publishes `captured_at` — the snapshot's own collection instant — and the anchored ones publish `age_seconds` against the window's end. Read it before treating a "current" figure as current: the newest row a store holds is as old as its collector's last successful run, and the configuration family is captured on CONNECT, so a "current" setting can be days old. Where a latest read takes `hours_back`, its description says which of two things that means: the span SEARCHED for the newest snapshot (`get_latch_stats`, `get_cpu_scheduler_pressure`, `get_plan_cache_bloat` — a snapshot older than that is `unavailable`, not served as current), or a span READ beside the snapshot (`get_resource_semaphore` / `get_memory_grants` return `grants[]`, the newest snapshot, AND `window[]`, the peak / floor / summed-delta aggregate over every snapshot in the hours). `get_server_summary` carries three clocks by name — `cpu_captured_at`, `memory_captured_at`, and `last_collection` (the newest collection of ANY collector, which is the store's freshness and not the age of the two figures). - - The analysis family DOES take it (#2506), and the anchor reaches the ENGINE rather than stopping at the tool: `get_analysis_facts` and `analyze_server` re-run fact collection, anomaly detection and scoring over the anchored window, so the window is compared against the hour-of-day x day-of-week baseline for the hours it actually covers instead of for the hours you happen to be asking in. `compare_analysis` hangs BOTH windows off the anchor, since `baseline_hours_back` has always been measured from the comparison window's end. `get_analysis_findings` is the odd one and worth reading twice: its window is on ANALYSIS TIME, so anchoring it asks what a scheduled analysis pass was SAYING then, which is a different question from re-analyzing that window now (that is `analyze_server` with the same anchor). - - `analyze_server` with an `as_of` is EXPLORATORY and does NOT persist its findings; the result says so in `persisted` / `persistence_note`. A finding row is stamped with the time the analysis RAN, and `get_analysis_findings` and the viewer's Recommendations tab treat the newest `analysis_time` as the server's CURRENT state — so writing a backdated run would make last week's findings today's headline and would inflate the occurrence stats of any live incident sharing a story path. Run it without `as_of` when you want the present analyzed and recorded. - - `get_pvs_stats` does not take it. It mixes a latest-snapshot measurement with a windowed trend, so anchoring only the windowed half would return a result whose two halves describe different instants. - - ### Discovery & Health Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `list_servers` | Lists all monitored SQL Server instances with status and last collection time | none | - | `get_collection_health` | Shows collector health: running, failing, or stale, plus the server's sweep_pressure block: a `verdict` for SUSTAINED demand (a SATURATED body collects at a multiple of its configured cadence with every collector healthy) and a separate `peak_cycle_risk` for a SINGLE sweep (BODY_OVERRUN means one scheduled body cannot fit the budget even when the verdict reads OK, the signature of one infrequent heavy collector; `peak_collector` names it). Per-collector rows carry `avg_duration_ms`, `p95_duration_ms` and `max_duration_ms`: a mean far below the p95 means the collector's runs come in two sizes and the mean describes neither; carries analysis_caveats when a recent analysis pass could not read a fact family | `server_name` | - | `get_collection_log` | The RAW per-run collection log behind that rollup: one row per collector run with total duration split into time on the monitored server and time on the store, rows collected, status and any error. Reach for it when the rollup reads HEALTHY and collection still looks wrong, or to see what a collector was doing during a specific window. Newest first, or SLOWEST first when `min_duration_ms` is supplied — a duration floor under newest-first ordering cannot reach the tail, so the two are one decision and `order` names which you got. All THREE filters are applied in SQL BEFORE the cap, so `run_count` and `truncated` describe the MATCHING rows. `status` is the FAILURE filter and the one to reach for during an incident — "show me the failures" is what this log exists to answer, and paging the newest-first tail cannot reach a failure that is not recent; an unknown value is REFUSED naming the accepted set, never applied as a filter that matches nothing, and the applied value is echoed as `status_filter` (not `status`, which on a miss is the miss word). `hours_back` is the span you asked for; `oldest_returned_collection_time` and `newest_returned_collection_time` bound the PAGE you got (under the default ordering that is also the reach; under a duration floor the page is a cost-ranked sample and its oldest row says nothing about reach), and the cap can make those differ by orders of magnitude — read them before concluding anything from the rows. An empty result distinguishes THREE states: filters that matched nothing (`empty`, and it says nothing about the window as a whole), a quiet window (`empty`, widen it), and a server that has never collected (`unavailable`, collection is not running) | `server_name`, `hours_back`, `limit`, `as_of`, `collector_name`, `min_duration_ms`, `status` | - | `get_current_waits_trend` | The two Current Waits series over time: waiting-task total wait per wait type per collection, and blocked-session counts per database per collection. `get_waiting_tasks` gives the snapshot and can never say whether now is worse than an hour ago; this is that question. Read the two series together — a wait-type spike with no blocked sessions is a resource wait, the same spike with them is contention. An empty result distinguishes a genuine all-clear (`empty`) from a server the collector has never sampled (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back`, `database_name`, `as_of` | - | `get_blocking_stats` | Blocking SEVERITY per minute: blocking duration (event count, total, max, avg wait) and deadlock severity (victim count plus total/max/avg wait across EVERY process in the graphs, not just victims). `get_blocking_trend` and `get_deadlock_trend` say how OFTEN; this says how BAD — ten one-second blocks and one ten-minute block are the same count and a different problem. An empty result distinguishes a genuinely clear window (`empty`) from a server where neither capture path has ever produced a row (`unavailable`), which is NOT a clean bill of health | `server_name`, `hours_back`, `as_of` | - | `get_server_summary` | Quick health overview: CPU %, memory, blocking/deadlock counts; three clocks named (`cpu_captured_at`, `memory_captured_at`, `last_collection` = newest collection of ANY collector) | `server_name` | - | `get_daily_summary` | Daily composite health band + wait/query/deadlock/blocking/CPU/memory/alert rollup for one day. A day before the store's `retention_horizon` is `unavailable` with `data_state: purged` — never a band, because its zeros are absences | `server_name`, `summary_date` (yyyy-MM-dd ONLY, refused otherwise; default today) | - | `get_daily_summary_range` | The SAME rollup across a span of days — one row per collected day, the Performance Calendar's month grid. Use it when the question is WHICH day rather than how one day went: scan the bands, then call `get_daily_summary` for the day that stands out. A day with ANY collection appears even when every signal was quiet, so a day absent from the result is a gap in COLLECTION — inside retention. The payload publishes `retention_horizon` (the oldest day every signal table still holds) and `days_before_horizon`; a returned day before the horizon is `data_state: purged` (no signal table holds it) or `past_horizon` (some still do) and `NoData`, NEVER Healthy, because a purged day's per-signal zeros are absences left by the purge. Purged and past-horizon rows carry no verdict; `no_run_record` (inside retention, no collector run recorded) keeps its band with a caveat. `as_of` anchors the LAST day of the range. An empty result distinguishes a range outside this server's history (`empty`) from a server nothing has ever been collected for (`unavailable`) | `server_name`, `days_back` (default 30, max 366), `as_of` | - - ### Wait Statistics Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_wait_stats` | Top wait types aggregated over time period, heaviest first; page bounded by `limit`, `truncated` says the window observed more | `server_name`, `hours_back` (default 24), `limit` (default 20), `as_of` | - | `get_wait_types` | Lists distinct wait types observed (use before `get_wait_trend`). An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server no wait stats have ever been stored for (`unavailable`) | `server_name`, `hours_back`, `as_of` | - | `get_wait_trend` | Time-series for one wait type | `wait_type` (required), `server_name`, `hours_back`, `as_of`, `bucket_minutes` | - | `get_waiting_tasks` | Currently/recently waiting queries with details, newest capture first. Page bounded by `limit`; `truncated` and `oldest_returned_collection_time` say what it reached | `server_name`, `hours_back` (default 1), `limit`, `as_of` | - - ### CPU Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_cpu_utilization` | SQL Server CPU vs other process CPU over time | `server_name`, `hours_back` (default 4), `as_of`, `bucket_minutes` | - | `get_cpu_scheduler_pressure` | Latest scheduler snapshot within `hours_back` of `as_of`: runnable queue depth, worker-thread utilization, queued/blocked requests, pressure warnings, banded `pressure_level` + `recommendation`; `captured_at` / `age_seconds` | `server_name`, `hours_back` (default 24; the span SEARCHED for the newest snapshot), `as_of` | - - ### Contention Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_latch_stats` | Latest latch-contention snapshot by class (waits + last-interval delta waits, `waits_per_second` / `wait_ms_per_second` over `interval_seconds` — all null with the deltas on a restart row, the same keys Darling's twin spells); `captured_at` / `age_seconds`; a PAGE — `latches_returned` / `truncated` | `server_name`, `hours_back` (default 24; the span SEARCHED for the newest snapshot), `limit` (default 20), `as_of` | - | `get_spinlock_stats` | Latest spinlock-contention snapshot (collisions, spins, backoffs; `collisions_per_second` / `spins_per_second` over `interval_seconds` — all null with the deltas on a restart row); `captured_at` / `age_seconds`; a PAGE — `spinlocks_returned` / `truncated` (well over a hundred spinlocks exist; the default page is the hot tail) | `server_name`, `hours_back` (default 24; the span SEARCHED for the newest snapshot), `limit` (default 20), `as_of` | - - ### Plan Cache Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_plan_cache_bloat` | Single-use vs multi-use plan composition per cache/object type, with bloat-level classification; `captured_at` / `age_seconds` | `server_name`, `hours_back` (default 24; the span SEARCHED for the newest snapshot), `as_of` | - - ### Query Performance Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_top_queries_by_cpu` | Expensive queries from plan cache with DOP, spills, query_hash. `max_dop` is a lifetime-max for the cached plan, not current parallelism - confirm with `analyze_query_plan`. `cpu_attribution.attributed_cpu_ratio` says how much of the box's measured CPU the returned rows explain | `server_name`, `hours_back`, `top`, `database_name`, `parallel_only`, `min_dop`, `as_of` | - | `get_top_procedures_by_cpu` | Expensive stored procedures by CPU time, with the same `cpu_attribution` disclosure | `server_name`, `hours_back`, `top`, `database_name`, `as_of` | - | `get_query_store_top` | Expensive queries from Query Store (persistent) | `server_name`, `hours_back`, `top`, `database_name`, `as_of` | - | `get_query_heatmap` | The desktop viewer's Query Heatmap as a TABLE: how many DISTINCT queries fell into each (time bin x log-magnitude bucket) cell, with the most-executed query in each. The only query read with a TIME axis - the rankings above cannot show that a window had a quiet half and a bad half. Bins are 5 minutes by default, the viewer's own width, so both surfaces draw the same picture; raise `bucket_minutes` to cover a longer window in fewer cells. `limit` caps CELLS and truncation drops the OLDEST bins, so read `first_time_bin` / `last_time_bin`. Zero cells is three states and the read says which: never collected (`unavailable`), nothing in the window (`empty`, widen it), or collected and genuinely idle (`empty`) | `server_name`, `hours_back`, `metric`, `database_name`, `bucket_minutes`, `limit`, `as_of` | - | `get_query_store_regressions` | Queries whose Query Store performance got WORSE: the recent window's averages vs the BASELINE (every capture before it), with duration / CPU / reads regression percents, the execution-count-weighted extra duration (the ranking key), the plan counts on both sides and a severity band. `get_query_store_top` answers what is EXPENSIVE; this answers what CHANGED. Kept only where average CPU regressed > 25%. Zero rows is four states and the read says which — including "no baseline, so no regression is detectable", which is NOT a clean bill of health A percent whose baseline side is 0 (e.g. no logical reads before the window, 50k inside it) has no denominator and is null with the reason under `undefined_percents` — never 0, which would read as no change; the ranking key is an absolute delta and exists for every row, and `severity` is null when the duration percent is. | `server_name`, `hours_back`, `database_name`, `limit`, `as_of` | - | `get_query_trend` | Time-series for a specific query by query_hash. Carries the same `source` / `effective_start` / `window_truncated` / `bucket` block as the duration trends (always `raw`, `per-collection` on Lite; the window floor is `window_truncated`, spelled `truncated` before #3653) | `query_hash` (required), `database_name` (required), `server_name`, `hours_back`, `as_of` | - | `get_query_duration_trend` | Overall query elapsed-ms/sec + executions/sec over time, from the PLAN CACHE. `execution_count` and `executions_per_second` are the same quantity; the first is truncated to an integer, so read the second on a quiet server. Each point also carries `elapsed_ms_per_second`, the same quantity as `value` with its unit in the name — read that one — and `peak_elapsed_ms_per_second`, the point's busiest collection. Points are time buckets sized to the window (`bucket` / `bucket_minutes`, `aggregate_note`). The payload says what it served: `source` (always `raw` on Lite — DuckDB keeps every collected row for the collector's `retention_days`, nothing rolls them up), `effective_start` / `effective_hours_back` (where the series the store actually held begins) and `window_truncated` (true when that head sits later than asked — a server added mid-window, or a retention purge shorter than `hours_back`). Same field set as Darling's twin, whose `source` can also be `hourly`. An empty result carries the same block and distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running). Rates are over each collection's STORED `sample_interval_seconds`; a collection whose interval was unknowable (a restart or counter reset, stored as 0, or the window's first collection when none was stored) is left out of its bucket, never counted as 0 (`unrated_collections`), and a bucket holding nothing else carries null rates (`unrated_points` / `unrated_note`). | `server_name`, `hours_back`, `as_of`, `bucket_minutes` | - | `get_procedure_duration_trend` | The same series over procedure_stats. NOT a duplicate: query_stats smears a procedure's work across the statements inside it, this charges the whole call to the procedure. Read both to tell an ad-hoc regression from a procedure regression. Same buckets, disclosure block and rating rule as `get_query_duration_trend` | `server_name`, `hours_back`, `as_of`, `bucket_minutes` | - | `get_query_store_duration_trend` | The same series over Query Store, which persists per interval and survives a plan-cache eviction or a restart. Each interval is counted once, at the hour the work RAN. Its `unavailable` names the cause the plan-cache trends do not have: Query Store may be OFF on every database. Same `source` / `effective_start` / `window_truncated` / `bucket` block as its siblings, with `bucket` `per-interval` | `server_name`, `hours_back`, `as_of` | - - ### Blocking & Deadlock Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_deadlocks` | Recent deadlock events with victim info, newest first. Page bounded by `limit`; `truncated` and `oldest_returned_deadlock_time` say what it reached | `server_name`, `hours_back`, `limit`, `as_of` | - | `get_deadlock_detail` | Full deadlock graph XML for deep analysis, newest first; `limit` counts graphs, `truncated` says the window held more | `server_name`, `hours_back`, `limit`, `as_of` | - | `get_blocked_process_reports` | Parsed blocking from sp_HumanEventsBlockViewer (extended events) + the always-on DMV fallback, newest first. The page is bounded by `limit`, not `hours_back`: read `truncated` and `oldest_returned_event_time` before treating the page as the window | `server_name`, `hours_back`, `limit`, `as_of` | - | `get_blocked_process_xml` | Raw blocked process report XML, newest first; `limit` counts reports with XML, `truncated` says the window held more | `server_name`, `hours_back`, `limit`, `as_of` | - | `get_long_query_completions` | The window's SLOWEST completed queries (rpc/batch over the trace threshold) + attentions/cancels from the opt-in long-query trace, duration DESC (empty until the collector is enabled). `truncated` says the window held more than `limit`; the page's time stamps bound the slowest runs, not the reach | `server_name`, `hours_back`, `limit`, `as_of` | - | `get_blocking_trend` | Time-series of blocking event counts. An empty result distinguishes a genuine all-clear (`empty`, with the collector run counts in `hints` so you can see how many captures the window actually holds) from a window no collector covered (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back`, `as_of` | - | `get_deadlock_trend` | Time-series of deadlock event counts. An empty result distinguishes a genuine all-clear (`empty`, with the collector run counts in `hints` so you can see how many captures the window actually holds) from a window no collector covered (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back`, `as_of` | - | `get_lock_wait_trend` | The whole LCK% family's wait milliseconds per SECOND, summed across types and bucketed, with a `wait_types` legend of the types that waited — the aggregate lock-wait lane. The two trends above count incidents and `get_wait_trend` charts ONE named wait type; this shows lock pressure rising when no single type dominates. An empty result distinguishes a genuinely quiet window (`empty`, widen `hours_back`) from a server no wait stats have ever been stored for (`unavailable`), which is NOT a report of a server without lock contention | `server_name`, `hours_back`, `as_of`, `bucket_minutes` | - - ### Memory Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_memory_stats` | Latest memory snapshot: physical, buffer pool, plan cache; `captured_at` | `server_name` | - | `get_memory_trend` | Memory usage over time. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back`, `as_of`, `bucket_minutes` | - | `get_memory_clerks` | Top memory consumers by clerk type. An empty result is `unavailable`, never a quiet period — a live SQL Server always has clerks, so nothing retained means the collector has not run or its rows aged out | `server_name` | - | `get_memory_grants` | Per-pool grant pressure: `grants[]` = newest snapshot in the window (`captured_at` / `age_seconds`) AND `window[]` = peak waiters (+ when), peak grant, available floor, summed timeout / forced deltas over EVERY snapshot in the window | `server_name`, `hours_back` (default 1), `as_of` | - | `get_resource_semaphore` | Per-semaphore workspace memory vs target/max ceiling: `grants[]` = newest snapshot in the window (`captured_at` / `age_seconds`) AND `window[]` = the same peak / floor / summed-delta aggregate per (semaphore, pool) | `server_name`, `hours_back` (default 24), `as_of` | - | `get_memory_pressure_events` | Ring buffer memory pressure notifications (sp_pressuredetector source) | `server_name`, `hours_back`, `as_of` | - - ### I/O Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_file_io_stats` | Latest file I/O stats per database file with latency; `captured_at` | `server_name` | - | `get_file_io_trend` | Read/write latency over time per database and file type, the five with the most I/O stall on their own lines and the rest in one `(other)` line; `database_name` charts one database per file. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back`, `as_of`, `bucket_minutes`, `database_name` | - - ### TempDB Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_tempdb_trend` | TempDB space: user objects, internal objects, version store | `server_name`, `hours_back`, `as_of`, `bucket_minutes` | - - ### Storage & Index Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_table_index_sizes` | The 100 largest tables with size, growth and row counts from the latest daily snapshot. Growth spans only history the store holds: `history` says how many days exist and whether the 7d/30d baselines are reachable; `growth_7d_mb` / `growth_30d_mb` / `growth_pct_30d` are null (reason in `growth_note`) when their baseline does not exist — never re-measured over a shorter span under the same name — and `growth_over_available_history_*` spans exactly `growth_window_days` | `server_name` | - | `get_index_usage` | Per-index seeks/scans/lookups/updates with Unused/Write-only/Active classification (drop candidates first) | `server_name` | - | `get_object_locking` | Per-index lock/latch waits and lock escalations, top contended objects, at the newest index/object snapshot — `captured_at` is the instant it was collected, and the collection is DAILY, so on a healthy server the stamp is routinely hours old | `server_name` | - - ### Performance Counter Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_perfmon_stats` | Latest perfmon counters (batch requests/sec, etc.); `captured_at` | `server_name`, `counter_name`, `instance_name` | - | `get_perfmon_trend` | Time-series for one perfmon counter | `counter_name` (required), `server_name`, `hours_back`, `as_of`, `bucket_minutes` | - - ### Alert Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_alert_history` | Recent alert history: what fired, when, email status, newest first. EXCLUDES operator-dismissed alerts by default and says so (`dismissed_excluded`, `dismissed_excluded_count`); pass `include_dismissed` when reconstructing an incident. Page bounded by `limit`; `truncated` and `oldest_returned_alert_time` say what it reached | `hours_back` (default 24), `limit` (default 50), `as_of`, `include_dismissed` (default false) | - | `get_alert_settings` | Every alert group's enable flag and thresholds (CPU, blocking, deadlocks, poison waits, long-running queries/jobs, tempdb, low disk, PVS, file growth, failed jobs, database state), plus cooldown, excluded databases, delivery mode, analysis cadence and SMTP configuration | none | - | `get_mute_rules` | Configured mute rules that suppress specific recurring alerts (still logged). An empty result distinguishes no rule ever written from rules that exist but have all lapsed, with the configured count in `hints` | `enabled_only` (default true) | - - ### Job Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_running_jobs` | Currently running SQL Agent jobs with duration vs historical average/p95 | `server_name` | - - ### Configuration Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_server_config` | sp_configure settings with configured and in-use values; `captured_at` (captured on connect — can be days old) | `server_name` | - | `get_database_config` | Database-level settings: RCSI, recovery model, auto-shrink, Query Store, etc.; `captured_at` (captured on connect) | `server_name`, `database_name` | - | `get_database_scoped_config` | Database-scoped configuration (MAXDOP, legacy CE, parameter sniffing); `captured_at` (captured on connect) | `server_name`, `database_name` | - | `get_query_store_health` | Per-database Query Store health (latest hourly snapshot) — actual vs desired state, readonly_reason decoded, storage vs cap, cleanup thresholds, and the two capture modes (`query_capture_mode` ALL / AUTO / CUSTOM / NONE — ALL is the plan-churn factory; `wait_stats_capture_mode` ON / OFF; null = pre-rung row or pre-2017 engine) | `server_name`, `database_name` | - | `get_trace_flags` | Active trace flags with global/session scope; `captured_at` (captured on connect) | `server_name` | - | `get_server_config_changes` | sp_configure change history (diff of on-connect snapshots) | `server_name`, `hours_back` (default 168), `as_of` | - | `get_database_config_changes` | sys.databases change history (recovery model, RCSI, compat level, etc.) | `server_name`, `hours_back` (default 168), `as_of` | - | `get_trace_flag_changes` | Trace flag enable/disable history (diff of on-connect snapshots) | `server_name`, `hours_back` (default 168), `as_of` | - - ### System Health & Default Trace Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_default_trace_events` | Significant Default Trace events: file auto-grow/shrink stalls, severe ErrorLog writes, schema DDL, security audits (`event_time` is UTC — the server-local trace StartTime, de-skewed) | `server_name`, `hours_back` (default 24), `limit` (default 100), `as_of` | - | `get_health_parser_system_health` | Parsed sp_server_diagnostics health counters (spinlocks, latch warnings, dumps, CPU, bad pages) | `server_name`, `hours_back`, `limit`, `as_of` | - | `get_health_parser_severe_errors` | Severe errors (severity >= 19) from system_health | `server_name`, `hours_back`, `limit`, `as_of` | - | `get_health_parser_io_issues` | I/O warnings from system_health (15-second I/O, long/pending I/O) | `server_name`, `hours_back`, `limit`, `as_of` | - | `get_health_parser_scheduler_issues` | Non-yielding schedulers and scheduler-monitor warnings | `server_name`, `hours_back`, `limit`, `as_of` | - | `get_health_parser_memory_conditions` | Low-memory snapshots (RESOURCE_MEMPHYSICAL_LOW) with the memory-manager report | `server_name`, `hours_back`, `limit`, `as_of` | - | `get_health_parser_cpu_tasks` | CPU task/worker-thread snapshots (QUERY_PROCESSING) with deadlock/blocking flags | `server_name`, `hours_back`, `limit`, `as_of` | - | `get_health_parser_memory_broker` | Memory broker ratio changes and target adjustments | `server_name`, `hours_back`, `limit`, `as_of` | - | `get_health_parser_memory_node_oom` | Per-NUMA-node out-of-memory events | `server_name`, `hours_back`, `limit`, `as_of` | - | `get_health_parser_significant_waits` | Individual wait_info events: a real session's non-BACKUP statement waited 500 ms+ on a non-idle wait type, with the wait type, duration and signal duration, resource, session id and the waiting SQL text. `get_wait_stats` gives the instance-wide totals and can never name the statement that paid them. An empty result says which nothing it is: events captured but none significant (the healthy answer), a quiet window (`empty`), or wait_info never captured (`unavailable`, NOT an all-clear) | `server_name`, `hours_back`, `limit`, `as_of` | - - Every one of the nine carries a SOURCE WITNESS: `source_observed` (whether this server's system_health session has EVER been read into the store — any event of any type) and `last_captured_at` (the collector's newest capture). Zero rows is four different answers and the read says which: captured in the window and gated out (`empty`, `events_in_window` > 0 — the healthy one), captured before but not in this window (`empty`, `last_captured_of_type_at` says when — widen), never captured for THIS category while the session is being read (`empty`, `source_observed` true — for a rare category such as a memory-node OOM this is the measurement, not a gap), and nothing of any type ever (`unavailable`, `source_observed` false — a dead session or a collector that never ran, and NOT an all-clear). Zero is a measurement only when the source was observed. - - ### Server Information Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_server_properties` | Server inventory: edition, version, CPU count, memory, socket topology | `server_name` | - | `get_database_sizes` | Database file sizes, space usage, and volume free space | `server_name` | + Every tool below that takes `hours_back` also takes `as_of`: an optional ISO-8601 UTC instant that moves the END of the window off "now"; `hours_back` stays the LENGTH. Reach for it whenever the question is about a time rather than about the present, and do NOT substitute a wider `hours_back`: for an aggregate read a wider window is a DIFFERENT answer, not the same answer with more rows. - ### Session & Active Query Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `get_active_queries` | Active query snapshots from sys.dm_exec_requests — what was running at each collection point. `database_name` / `blocking_only` are applied IN the query; `total_snapshots` counts the filtered population, `snapshots_returned` the page, `truncated` says the population held more. Head blockers a victim names are never stripped (`is_head_blocker`); a victim whose blocker is absent says why in `blocker_not_shown` (`not_captured` / `filtered` / `past_page`) | `server_name`, `hours_back` (default 1), `database_name`, `blocking_only`, `limit`, `as_of` | - | `get_session_stats` | Connection counts and resource usage grouped by application | `server_name` | + - Accepted: full ISO-8601 with an offset, or a bare `2026-08-19` (midnight UTC). An unparseable or future `as_of` is REFUSED, not read as "now"; an older one returns the normal `empty` / `unavailable`. + - Latest-snapshot reads take no `as_of` — read `captured_at` / `age_seconds` instead. `get_pvs_stats` never takes it. + - The analysis family DOES take it: `get_analysis_facts` and `analyze_server` re-run fact collection, anomaly detection and scoring over the anchored window (`analyze_server` with `as_of` is EXPLORATORY, does not persist). - ### Execution Plan Analysis Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `analyze_query_plan` | Analyze plan from plan cache by query_hash | `query_hash` (required), `server_name` | - | `analyze_procedure_plan` | Analyze procedure plan by plan_handle | `plan_handle` (required), `server_name` | - | `analyze_query_store_plan` | Analyze plan from Query Store (fetches on-demand from SQL Server) | `database_name` (required), `plan_id` (required), `server_name` | - | `analyze_plan_xml` | Analyze raw showplan XML directly | `plan_xml` (required) | - | `get_plan_xml` | Get raw showplan XML by query_hash | `query_hash` (required), `server_name` | + Each finding's `confidence` is an EVIDENCE score (0.20 for the symptom alone, more as the root fact's amplifier checks match and the chain deepens — a lone uncorroborated symptom is 0.20, never 1.0) and `confidence_basis` says in words what it rests on (an ANOMALY_* fact's `baseline_confidence` is the baseline's trustworthiness, not a finding's `confidence`). - Plan analysis detects 31 performance anti-patterns including: - - Missing indexes as column lists (equality / inequality / include) with the optimizer's statement-scoped impact estimate (`impact_basis`) and its suggested `create_statement` — corroboration for a statement already measured slow, never a diagnosis; every row carries the fixed `caveat` (per-statement estimate, per-table commitment with write cost and regression risk for other plans — test it) - - Non-SARGable predicates, implicit conversions, data type mismatches - - Memory grant issues, spills to TempDB - - Parallelism problems: serial plan reasons, thread skew, ineffective parallelism - - Parameter sniffing (compiled vs runtime value mismatches) - - Expensive operators: key lookups, scans with residual predicates, eager spools - - Join issues: OR clauses, high nested loop executions, many-to-many merge joins - - UDF execution overhead, table variable usage, CTE multiple references + | Tool | Note | + |------|------| + | `get_query_store_health` | Per-database Query Store health (latest hourly snapshot) — actual vs desired state, readonly_reason decoded, storage vs cap, cleanup thresholds, and the two capture modes (`query_capture_mode` ALL / AUTO / CUSTOM / NONE — ALL is the plan-churn factory; `wait_stats_capture_mode` ON / OFF; null = pre-rung row or pre-2017 engine) | - ### Diagnostic Analysis Tools - | Tool | Purpose | Key Parameters | - |------|---------|----------------| - | `analyze_server` | Runs the inference engine: scores facts, traverses relationship graph, returns evidence-backed findings with severity and recommended next tools. Each finding's `confidence` is an EVIDENCE score (0.20 for the symptom alone, more as the root fact's amplifier checks match and the chain deepens — a lone uncorroborated symptom is 0.20, never 1.0) and `confidence_basis` says in words what it rests on; rank by `severity` for impact and read `confidence` as how much of the engine's own corroboration showed up. A remediable finding also carries `remediation_command` — the full copy-paste T-SQL remediation (identical to the viewer card), with a two-sided risk-disclosure header on destructive changes; advisory only, never executed. Force-plan findings additionally carry `structured_remediation`: the verdict as machine-readable fields (eligible + named blockers), evidence, and split force/unforce/verify SQL. With `as_of` it analyzes a PAST window — anomaly baseline included — and is EXPLORATORY: the findings come back in full but are NOT persisted, which `persisted` / `persistence_note` state on every result | `server_name`, `hours_back` (default 4), `as_of` | - | `get_analysis_facts` | Exposes raw scored facts from the collect+detect+score pipeline — every observation the engine sees, the anomaly detector's ANOMALY_* facts included, with base severity, amplifiers, and metadata (an ANOMALY_* fact's `baseline_confidence` is the baseline's trustworthiness, not a finding's `confidence`) | `server_name`, `hours_back` (default 4), `source` (one of the engine's 15 registered sources — anomaly, bad_actor, blocking, config, coverage, cpu, database_config, disk, io, jobs, memory, queries, sessions, tempdb, waits; an unknown value is refused with the set), `min_severity`, `as_of` | - | `compare_analysis` | Compares two time periods (e.g., peak vs off-peak, yesterday vs today, the windows around a change), banding each fact worse / better / stable by how far its VALUE moved on the server's own scale — in the stored per-server baseline's robust sigma where one exists (`delta_sigma`, `band_source` `baseline`), otherwise only when the value moved at least a quarter of the larger side AND registers a quarter of the way up its own severity ladder (`band_source` `absolute`); `band_rules` states the rules on every payload. Rows are grouped into physical-cause `families` (one I/O stall is one family row), and `BAD_ACTOR_` appearances are `plan_cache_churn`, not new or resolved issues. A verdict is a DIFFERENCE, not an experiment: same-hour-yesterday at N=1 vs N=1 cannot show that a change caused anything. When NEITHER window produced facts the result is `unavailable` rather than an all-zero comparison, because "nothing to compare" is not "nothing changed"; when only ONE window is empty the payload carries a `caveat` saying so; a partly collected side flags every verdict row with `coverage_caveat`. `baseline_hours_back` is measured from the comparison window's END, so `as_of` moves BOTH windows together | `server_name`, `hours_back` (default 4), `baseline_hours_back` (default 28), `as_of` | - | `audit_config` | Configuration audit: evaluates CTFP, MAXDOP, max memory, and max worker threads against best practices and the server's resources (edition is reported, not consulted) | `server_name` | - | `get_analysis_findings` | Retrieves persisted findings from previous analysis runs (each with `confidence_basis`: rows persisted before `confidence` measured corroboration are labelled `path-shape (pre-#3538)` — under that formula a lone symptom read 1.0, so do not read those as corroborated), deduplicated to one entry per diagnostic chain (`story_path_hash` + `incident_id`): the latest occurrence plus `occurrences`/`first_seen`/`last_seen`/`peak_severity` spanning the window; each remediable finding carries `remediation_command` — the full copy-paste T-SQL remediation (identical to the viewer card), rendered from the persisted action, advisory only and never executed; force-plan findings additionally carry `structured_remediation` (verdict + evidence + split artifacts, machine-readable). Its window is on ANALYSIS TIME, so `as_of` asks what analysis was saying then rather than re-analyzing that window now | `server_name`, `hours_back` (default 24), `as_of` | - | `mute_analysis_finding` | Mutes a finding pattern by story_path_hash so it won't appear in future runs. Idempotent per scope: the same hash muted twice in the same scope writes nothing the second time (status `already_muted`, `registered` false). Reports what the write did: `registered`, `already_muted`, `story_path` (the chain the row names, null when no retained finding carries the hash), and `matched_now` — how many stored findings in scope carry the hash (status `muted_unmatched` when 0: the mute is kept, but check the hash) | `story_path_hash` (required), `server_name`, `reason` | + ## Memory pressure signals - ## Recommended Workflow + `get_memory_pressure_events`' indicator scale: 0-1 normal; 2 (medium) Resource Monitor trimming caches/grants; 3+ (severe) evicting buffer pool/plan cache, always worth investigating. `memory_indicators_process` is SQL Server itself under pressure (check `get_memory_grants`, `get_memory_clerks`); `memory_indicators_system` is Windows signalling low memory box-wide, which SQL Server may not be causing (check `get_server_properties`). - 1. **Start**: `list_servers` — see what's monitored and which servers are online - 2. **Verify**: `get_collection_health` — check collectors are running successfully - 3. **Diagnose**: `analyze_server` — run the inference engine for an evidence-backed assessment. Each finding includes `next_tools` — a list of recommended MCP tools to call for deeper investigation. Follow those recommendations. - 4. **Drill down** using the `next_tools` from findings, or manually: - - High waits → `get_wait_stats` → `get_wait_trend` for specific wait type - - CPU pressure → `get_cpu_utilization` → `get_top_queries_by_cpu` - - Blocking → `get_blocked_process_reports` for details - - Memory issues → `get_memory_stats` → `get_memory_clerks` → `get_memory_grants` - - I/O latency → `get_file_io_stats` → `get_file_io_trend` - - TempDB pressure → `get_tempdb_trend` - 5. **Deep dive**: Use `get_analysis_facts` to inspect what the engine sees, including amplifier details and raw metric values - 6. **Compare**: Use `compare_analysis` to see if problems are new (compare last 4 hours vs yesterday same time) - 7. **Config**: Use `audit_config` for resource-aware configuration recommendations (CTFP, MAXDOP, max memory, worker threads) - 8. **Active queries**: Use `get_active_queries` to see what was running at a specific time — critical for correlating CPU spikes, blocking events, or deadlocks with actual queries - 9. **Configuration**: Use `get_server_config`, `get_database_config`, or `get_database_scoped_config` to check server and database settings; `get_query_store_health` shows whether Query Store is actually working per database (the silent failure is desired READ_WRITE with actual READ_ONLY) - 10. **Query investigation**: After finding a problematic query via `get_top_queries_by_cpu`, use `get_query_trend` with its `query_hash` to see performance history - 11. **Plan analysis**: Use `analyze_query_plan` with the `query_hash` from step 10 to get detailed plan analysis with warnings, missing indexes, and optimization recommendations + ## Workflow - ## Wait Type to Tool Mapping - - When `get_wait_stats` reveals dominant wait types: - | Wait Type | Indicates | Tools to Use | - |-----------|-----------|--------------| - | `SOS_SCHEDULER_YIELD` | CPU pressure | `get_cpu_utilization`, `get_top_queries_by_cpu` | - | `CXPACKET` / `CXCONSUMER` | Parallelism | `get_top_queries_by_cpu` with `parallel_only=true` | - | `PAGEIOLATCH_*` | Disk I/O | `get_file_io_stats`, `get_file_io_trend` | - | `WRITELOG` | Transaction log I/O | `get_file_io_stats` (check log file latency) | - | `LCK_M_*` | Lock contention | `get_blocked_process_reports` | - | `RESOURCE_SEMAPHORE` | Memory grant pressure | `get_memory_grants` | - | `LATCH_*` | Internal contention | `get_tempdb_trend` | - - ## Blocked Process Reports - - - **`get_blocked_process_reports`**: Captures events from SQL Server's Blocked Process Report extended event (via sp_HumanEventsBlockViewer). Fires when a session has been blocked longer than the configured threshold. Includes rich detail: isolation levels, transaction names, and full query text for both the blocker and the blocked session. - - Use it for detailed analysis of prolonged blocking events; pair it with `get_blocking_trend` to see whether blocking frequency is new, worsening, or resolved. - - ## Interpreting Memory Pressure Events - - `get_memory_pressure_events` returns notifications from the `RING_BUFFER_RESOURCE_MONITOR` ring buffer. The `memory_indicators_process` and `memory_indicators_system` values are SQL Server's Resource Monitor signals. Indicator scale: - - - **0-1**: normal operating state, not actionable - - **2 (medium)**: Resource Monitor has crossed a threshold and is starting to respond — trimming caches, reducing memory grants. Worth investigating if sustained or frequent. - - **3+ (severe)**: aggressive response — buffer pool pages are being evicted, plan cache entries thrown out, workspace memory starved. Always worth investigating. - - The two indicators report different things: - - - `memory_indicators_process` — the SQL Server *process itself* is under memory pressure. Usually workload-induced (large memory grants, plan cache bloat, buffer pool churn). - - `memory_indicators_system` — Windows is signaling low memory *system-wide*. Something on the whole box is consuming memory; SQL Server may or may not be the culprit. - - ### What to check when process pressure (indicator >= 2) fires - - The workload is squeezing SQL Server itself. Follow-up tools: - | Signal to check | Tool | - |-----------------|------| - | Memory grant contention, workspace memory pressure | `get_memory_grants` | - | Buffer pool composition, memory clerk distribution | `get_memory_clerks` | - | Target vs total server memory (how close SQL is to its memory target) | `get_memory_stats`, `get_memory_trend` | - | Queries that requested large grants during the window | `get_top_queries_by_cpu` | - | `RESOURCE_SEMAPHORE` waits in the same window | `get_wait_stats`, `get_wait_trend` | - - ### What to check when system pressure (indicator >= 2) fires but process does not - - The box is tight on memory, but SQL Server's own process is not the cause. SQL Server feels Windows' low-memory notification but isn't driving it. Typical root causes: other services on the machine (anti-virus, backup agents, monitoring agents, additional SQL instances, SSIS/SSRS, RDP sessions), oversized file system cache, or VM-host memory oversubscription. Follow-up: - - | Signal to check | Tool | - |-----------------|------| - | SQL Server's memory configuration (`max server memory` vs total RAM) | `get_server_properties` | - | Is SQL Server itself actually fine? | `get_memory_stats`, `get_memory_clerks` | - - Most of the diagnosis in this case is *outside* the monitored SQL instance — tell the user to check what else is running on the host. - - ### Patterns - - - **Both process and system firing together** → real capacity problem. Add RAM, tune the workload, or reduce concurrency. - - **Process only** → workload/schema issue, not a hardware problem. Tune queries and indexes. - - **System only** → non-SQL workload on the host; SQL itself is healthy but the tenant mix is tight. - - **Bursty spikes** → correlate the pressure window with `get_running_jobs` (scheduled maintenance, index rebuilds, big reports) and `get_top_queries_by_cpu` for that period. - - **Flat-line sustained** → chronic under-provisioning; memory needs to grow or workload needs to shrink. - - ## Tool Relationships - - - `get_wait_stats` identifies the symptom category (CPU, I/O, locks, parallelism). Other tools find the root cause. - - `get_perfmon_stats` provides throughput context (batch requests/sec, compilations/sec) that helps distinguish a busy server from a sick one. - - `get_top_queries_by_cpu` and `get_top_procedures_by_cpu` show aggregate query performance from sys.dm_exec_query_stats. `get_query_store_top` shows Query Store data which may include queries no longer in the plan cache. - - `get_query_trend` shows how a specific query (by query_hash) has performed over time — use it after identifying a problematic query. - - `get_waiting_tasks` shows what's actively waiting, complementing the aggregated view from `get_wait_stats`. - - `get_wait_types` helps you discover available wait types before drilling into `get_wait_trend`. - - Trend tools (`get_wait_trend`, `get_file_io_trend`, `get_memory_trend`, `get_blocking_trend`, `get_deadlock_trend`, `get_query_duration_trend`) confirm whether a problem is new, worsening, or steady-state. - - Query tools support `database_name` filtering and `parallel_only`/`min_dop` filtering to narrow results. Every filter is applied IN the query before the ranking and the cap (`filter_applied` names the parallelism floor in force), so the page is the top-N of the filtered population and an empty filtered page is the window's answer. + `list_servers` -> `get_collection_health` -> `analyze_server` (follow each finding's `next_tools`) -> `compare_analysis`/`audit_config` -> `mute_analysis_finding` to silence an accepted pattern. ## Important Limitations - - **ALL ACCESS IS READ-ONLY**. No exceptions. You cannot execute SQL or modify anything. - - Query text in results is truncated to 2000 characters. If you need the full text, note this to the user. - - CPU utilization data is downsampled to 1-minute averages to keep responses manageable. - - When a `server_name` parameter is omitted and multiple servers are configured, the tool will return an error listing available servers. Always specify the server when working with multi-server setups. - - ## Error Handling - - A tool that caught an exception answers with the `{"status":"error","message":...}` envelope described under "Reading an empty result" — the message carries the cause, and `hints.operation` names the tool. A tool that refused the call answers with the `{"status":"invalid","message":...}` envelope from the same section — `hints.parameter` names the knob to fix. The messages below are the `message` of a refusal or a miss, not failures: - - "Could not resolve server" — `invalid`: server name not found; the message lists the available servers, or use `list_servers` - - "No data available" — Collector hasn't run yet or no matching data in time range - - "Delta-based collection requires at least two cycles" — Wait ~30 minutes for newly added servers - - "Query Store may not be enabled" — Target database doesn't have Query Store enabled + ALL access is read-only. Query text is truncated to 2000 characters. CPU is downsampled to 1-minute averages. Omitting `server_name` with multiple servers configured lists the available ones. """; } diff --git a/Lite/Mcp/McpMemoryTools.cs b/Lite/Mcp/McpMemoryTools.cs index 6cd03b420f..b5c70f8fc1 100644 --- a/Lite/Mcp/McpMemoryTools.cs +++ b/Lite/Mcp/McpMemoryTools.cs @@ -176,7 +176,7 @@ Indicator scale (applies to both memory_indicators_process and memory_indicators memory_indicators_process = SQL Server process itself is under memory pressure (workload-induced). memory_indicators_system = Windows is signaling low memory system-wide (could be other tenants on the box). -Not available on Azure SQL DB (ring buffer not exposed). For actionable interpretation and suggested follow-up tools, see the 'Interpreting Memory Pressure Events' section of the server instructions.")] +Not available on Azure SQL DB (ring buffer not exposed). Process pressure: check get_memory_grants and get_memory_clerks. System pressure with process normal: check get_server_properties (likely another process on the box, not SQL Server).")] public static async Task GetMemoryPressureEvents( LocalDataService dataService, ServerManager serverManager,