From bc6b903ec2353bf7a25b400202194b4781730dd7 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:51:45 -0400 Subject: [PATCH 1/2] get_alert_history/set_mute_rule_enabled/create_mute_rule/set_notification_route_enabled move reading guidance off tools/list (#3898) Refs #3898. Converts the remaining alerting-family MCP tool descriptions to the two-tier head/tail shape: tools/list serves a short guardrail head plus a pointer, and get_tool_guide serves the full original prose as the tail. get_alert_history converts on both Darling and Lite with a byte-identical head (D6); the three mute-rule/route write tools are Darling-only. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M7r1CXmEcBmsrTV34CCLfF --- .../McpToolsListBudget/DarlingMcpAlertTools.txt | 10 +++++----- .../Mcp/DarlingMcpAlertTools.cs | 10 ++++++++-- Lite.Tests/McpToolsListBudget/McpAlertTools.txt | 4 ++-- Lite/Mcp/McpAlertTools.cs | 4 ++-- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpAlertTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpAlertTools.txt index 5bd9ccd302..38d0f3ecba 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpAlertTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpAlertTools.txt @@ -1,5 +1,5 @@ # DarlingMcpAlertTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks. -tool create_mute_rule 959 +tool create_mute_rule 528 param create_mute_rule.database_pattern 89 param create_mute_rule.expires_at 121 param create_mute_rule.job_name_pattern 79 @@ -15,10 +15,10 @@ param delete_mute_rule.rule_id 76 tool delete_notification_route 496 param delete_notification_route.route_id 54 -tool get_alert_history 4740 +tool get_alert_history 589 param get_alert_history.as_of 167 param get_alert_history.hours_back 29 -param get_alert_history.include_dismissed 360 +param get_alert_history.include_dismissed 114 param get_alert_history.limit 133 param get_alert_history.server_name 90 @@ -29,11 +29,11 @@ param get_mute_rules.enabled_only 54 tool get_notification_routes 502 -tool set_mute_rule_enabled 1477 +tool set_mute_rule_enabled 483 param set_mute_rule_enabled.enabled 88 param set_mute_rule_enabled.rule_id 87 -tool set_notification_route_enabled 841 +tool set_notification_route_enabled 512 param set_notification_route_enabled.enabled 85 param set_notification_route_enabled.route_id 65 diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs index eb49fcb231..33fa56465d 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs @@ -75,7 +75,7 @@ namespace PerformanceMonitor.Darling.Service.Mcp; [McpServerToolType] public sealed class DarlingMcpAlertTools { - [McpServerTool(Name = "get_alert_history"), Description("Gets recent alert history from the alert log, NEWEST FIRST: what alerts fired, when, for which server, the current vs threshold value, whether email/webhook delivery succeeded, and whether the alert was muted. Omit server_name to see the whole fleet (each row names its server); pass one to scope to a single server. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: alerts_returned is how many rows you got, truncated says the window held more than limit, and oldest_returned_alert_time / newest_returned_alert_time bound the page — under newest-first ordering the oldest stamp IS how far back this read reached, so on a noisy fleet a 24-hour request at the default limit may cover minutes. Raise limit or narrow hours_back when truncated is true; widening hours_back cannot help. BY DEFAULT THIS READ EXCLUDES DISMISSED ALERTS — rows an operator acknowledged in the Viewer's Alert History grid. Dismissal says nothing about whether the alert fired or mattered, so an incident reconstruction that ignores it can miss the very critical someone already looked at: dismissed_excluded says whether the filter applied and dismissed_excluded_count is how many rows in the window it removed, and include_dismissed = true returns them, each labelled dismissed = true. notification_type is the delivery disposition and is the ONLY field that says why a row did not deliver: 'email'/'webhook'/'email+webhook' delivered on that channel; 'throttled' means the delivery cooldown was still inside this alert's window so nothing was attempted (the throttle working, not a fault); 'folded' means a repeat was rolled onto another server's post for the same metric and is named there under 'Other Servers Affected', so it WAS reported; 'failed' means a channel was attempted and came back unsuccessful, with send_error carrying the first failing channel's text; 'unconfigured' means no email or webhook channel is set up; 'muted' means a mute rule suppressed it; 'digest' means the analysis finding was UNCORROBORATED (one fact in its chain, no matched co-fire check) and the corroboration gate routed it to the daily Analysis Singles Digest and the web/MCP surfaces instead of a paging channel (#3712) — nothing was attempted, nothing was suppressed, and routing_reason on the row says which components decided; 'none' is a resolution row, which no channel applies to. Do NOT split the not-delivered rows on send_error: it is null on 'throttled', 'folded' and 'digest' rows and on every row written before those values existed, so a null error is not evidence of a working cooldown. 'undelivered' is a retained legacy value that means throttled OR folded OR failed with nothing in the row to say which — count those rows separately rather than attributing them. severity is the row's tier — 'critical', 'warning', 'info' or 'resolution' — and severity_source says where it came from: 'fired' when the row persisted the tier the alert actually fired at (graded alerts such as Poison Wait, Volume Free Space and Database State fire Warning OR Critical by measurement), 'metric_name' when the row carries no tier and the metric's name is the only evidence (rows written before the tier was persisted, alerts whose severity is fixed per metric, and every resolution row). Do not infer a graded alert's tier from its name: a 'Poison Wait' row with severity 'warning' fired as a warning. route says WHERE the posts went (#3598): the alert's family (self-monitor / reports / agent-jobs / performance), the notification route that matched it (route_id null = the parent channels in Settings > Notifications answered everything) and each delivered channel with the route that supplied its destination — see get_notification_routes for the taxonomy and the routes. route is null on rows written before routing existed and on rows that never reached a delivery decision (throttled, folded, muted, unconfigured, digest, resolutions), so a null route is not evidence of a routing fault. routing is a DIFFERENT question from route (#3712): for an 'Analysis: …' finding it is the corroboration gate's decision one step UPSTREAM of the fan-out — 'page' when the finding earned a channel (two or more facts in its chain, or a matched co-fire check, or one of the two by-construction stories) and 'digest' when it was a lone uncorroborated fact — with routing_reason naming the components the gate read, so 'why didn't this page' is answered by the row. Both are null on every engine alert and on analysis rows written before the gate existed. To see what the digest carried, read the rows with notification_type 'digest' in the window: each is a persisted finding with its full detail_text and context, findable in get_analysis_findings by the story hash in its metric_name.")] + [McpServerTool(Name = "get_alert_history"), Description("Gets alert history, NEWEST FIRST: each row FIRED; notification_type says whether it was DELIVERED, a separate question from DISMISSED (UI-acknowledged), excluded by default. THE PAGE IS BOUNDED BY limit, NOT hours_back: truncated says the window held more, and oldest/newest_returned_alert_time bound how far the page reached. An EMPTY page can mean no alerts fired, or that every alert here was dismissed: dismissed_excluded_count says which; include_dismissed = true returns them, labelled dismissed = true. A null send_error proves nothing about delivery.<>Gets recent alert history from the alert log, NEWEST FIRST: what alerts fired, when, for which server, the current vs threshold value, whether email/webhook delivery succeeded, and whether the alert was muted. Omit server_name to see the whole fleet (each row names its server); pass one to scope to a single server. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: alerts_returned is how many rows you got, truncated says the window held more than limit, and oldest_returned_alert_time / newest_returned_alert_time bound the page — under newest-first ordering the oldest stamp IS how far back this read reached, so on a noisy fleet a 24-hour request at the default limit may cover minutes. Raise limit or narrow hours_back when truncated is true; widening hours_back cannot help. BY DEFAULT THIS READ EXCLUDES DISMISSED ALERTS — rows an operator acknowledged in the Viewer's Alert History grid. Dismissal says nothing about whether the alert fired or mattered, so an incident reconstruction that ignores it can miss the very critical someone already looked at: dismissed_excluded says whether the filter applied and dismissed_excluded_count is how many rows in the window it removed, and include_dismissed = true returns them, each labelled dismissed = true. notification_type is the delivery disposition and is the ONLY field that says why a row did not deliver: 'email'/'webhook'/'email+webhook' delivered on that channel; 'throttled' means the delivery cooldown was still inside this alert's window so nothing was attempted (the throttle working, not a fault); 'folded' means a repeat was rolled onto another server's post for the same metric and is named there under 'Other Servers Affected', so it WAS reported; 'failed' means a channel was attempted and came back unsuccessful, with send_error carrying the first failing channel's text; 'unconfigured' means no email or webhook channel is set up; 'muted' means a mute rule suppressed it; 'digest' means the analysis finding was UNCORROBORATED (one fact in its chain, no matched co-fire check) and the corroboration gate routed it to the daily Analysis Singles Digest and the web/MCP surfaces instead of a paging channel — nothing was attempted, nothing was suppressed, and routing_reason on the row says which components decided; 'none' is a resolution row, which no channel applies to. Do NOT split the not-delivered rows on send_error: it is null on 'throttled', 'folded' and 'digest' rows and on every row written before those values existed, so a null error is not evidence of a working cooldown. 'undelivered' is a retained legacy value that means throttled OR folded OR failed with nothing in the row to say which — count those rows separately rather than attributing them. severity is the row's tier — 'critical', 'warning', 'info' or 'resolution' — and severity_source says where it came from: 'fired' when the row persisted the tier the alert actually fired at (graded alerts such as Poison Wait, Volume Free Space and Database State fire Warning OR Critical by measurement), 'metric_name' when the row carries no tier and the metric's name is the only evidence (rows written before the tier was persisted, alerts whose severity is fixed per metric, and every resolution row). Do not infer a graded alert's tier from its name: a 'Poison Wait' row with severity 'warning' fired as a warning. route says WHERE the posts went: the alert's family (self-monitor / reports / agent-jobs / performance), the notification route that matched it (route_id null = the parent channels in Settings > Notifications answered everything) and each delivered channel with the route that supplied its destination — see get_notification_routes for the taxonomy and the routes. route is null on rows written before routing existed and on rows that never reached a delivery decision (throttled, folded, muted, unconfigured, digest, resolutions), so a null route is not evidence of a routing fault. routing is a DIFFERENT question from route: for an 'Analysis: …' finding it is the corroboration gate's decision one step UPSTREAM of the fan-out — 'page' when the finding earned a channel (two or more facts in its chain, or a matched co-fire check, or one of the two by-construction stories) and 'digest' when it was a lone uncorroborated fact — with routing_reason naming the components the gate read, so 'why didn't this page' is answered by the row. Both are null on every engine alert and on analysis rows written before the gate existed. To see what the digest carried, read the rows with notification_type 'digest' in the window: each is a persisted finding with its full detail_text and context, findable in get_analysis_findings by the story hash in its metric_name. Dismissal is an acknowledgement, not a verdict — a dismissed critical still fired — so set this when reconstructing an incident rather than triaging what is still open. Each row then carries dismissed so the two populations stay distinguishable.")] public static async Task GetAlertHistory( NpgsqlDataSource postgres, [Description("Server name or display name. Omit to return alerts across all servers (the fleet default).")] string? server_name = null, @@ -85,7 +85,7 @@ public static async Task GetAlertHistory( /* Appended after as_of for the reason get_collection_log's filters are: MCP invokes by name, the /api/read dispatch passes as_of by name, and a trailing optional is the one position no existing positional C# caller can be re-bound by. */ - [Description("Include alerts an operator has dismissed in the Viewer. Default false, which is the Alert History grid's own read. Dismissal is an acknowledgement, not a verdict — a dismissed critical still fired — so set this when reconstructing an incident rather than triaging what is still open. Each row then carries dismissed so the two populations stay distinguishable.")] bool include_dismissed = false) + [Description("Include alerts an operator has dismissed in the Viewer. Default false, which is the Alert History grid's own read.")] bool include_dismissed = false) { var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; @@ -567,6 +567,8 @@ empty array here with route_count 0 IS the answer "every alert goes to every par }; [McpServerTool(Name = "set_notification_route_enabled"), Description( + "Enables or disables a notification route by route_id, WITHOUT deleting it. Changes shared alert configuration the service delivers on: a disabled route is skipped at resolution as if it did not exist, so its alerts fall to the next level (family route, then parent channels). Reversible: re-enabling restores it exactly. Returns updated (route as stored), unchanged (already that value, safe to retry), or not_found. Cannot author or re-point a route; that is Settings-window only." + + "<>" + "Enables or disables a notification route by its route_id (from get_notification_routes) WITHOUT deleting it. " + "A disabled route is skipped at resolution exactly as if it did not exist, so the alerts it matched fall to " + "the next level (a family route, then the parent channels) on the next firing — the reversible form of " + @@ -894,6 +896,8 @@ consumer API and qualifying the existing entries would redefine every one of the } [McpServerTool(Name = "create_mute_rule"), Description( + "Creates an alert mute rule: matching alerts are still logged, just not delivered. Changes shared alert configuration the service delivers on. Provide any combination of scope/pattern fields; a field left out does not narrow the rule, so a rule with NO fields set matches and mutes EVERY alert across the whole fleet. No confirm step: the rule takes effect on the running service's next collection sweep. Returns the stored rule with its generated id, for delete_mute_rule or set_mute_rule_enabled." + + "<>" + "Creates an alert mute rule that suppresses matching alerts (they are still logged, just not delivered) — " + "the same rules get_mute_rules lists and the Viewer's Manage Mute Rules surface writes. Provide any " + "combination of the scope/pattern fields; a field left out does not narrow the rule, so a rule with NO " + @@ -1074,6 +1078,8 @@ internal static async Task DeleteMuteRuleCore(IMuteRuleStore store, stri } [McpServerTool(Name = "set_mute_rule_enabled"), Description( + "Enables or disables an existing mute rule by id, WITHOUT deleting it. Changes shared alert configuration the service delivers on. A disabled rule suppresses nothing but keeps its id, scope, reason and creation date, so silencing and restoring later is one reversible change, unlike delete+create, which loses those and resets the Stale Mute Rules age clock. Returns updated (rule as stored), unchanged (already that value, safe to retry), or not_found." + + "<>" + "Enables or disables an existing alert mute rule by its id (from get_mute_rules or create_mute_rule) " + "WITHOUT deleting it. A disabled rule suppresses nothing while keeping its id, its scope, its reason " + "and its creation date, so silencing a signal for a window and restoring it afterwards is one " + diff --git a/Lite.Tests/McpToolsListBudget/McpAlertTools.txt b/Lite.Tests/McpToolsListBudget/McpAlertTools.txt index d1e612fc34..fb88c3287c 100644 --- a/Lite.Tests/McpToolsListBudget/McpAlertTools.txt +++ b/Lite.Tests/McpToolsListBudget/McpAlertTools.txt @@ -1,8 +1,8 @@ # McpAlertTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks. -tool get_alert_history 3573 +tool get_alert_history 589 param get_alert_history.as_of 167 param get_alert_history.hours_back 29 -param get_alert_history.include_dismissed 357 +param get_alert_history.include_dismissed 111 param get_alert_history.limit 133 tool get_alert_settings 607 diff --git a/Lite/Mcp/McpAlertTools.cs b/Lite/Mcp/McpAlertTools.cs index 685321528d..06aad9317d 100644 --- a/Lite/Mcp/McpAlertTools.cs +++ b/Lite/Mcp/McpAlertTools.cs @@ -45,7 +45,7 @@ public sealed class McpAlertTools internal static string CpuModeFor(CpuAlertMode mode) => mode == CpuAlertMode.SqlOnly ? CpuModeSql : CpuModeTotal; - [McpServerTool(Name = "get_alert_history"), Description("Gets recent alert history from the alert log, NEWEST FIRST. Shows what alerts fired, when, and whether email was sent successfully. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: alerts_returned is how many rows you got, truncated says the window held more than limit, and oldest_returned_alert_time / newest_returned_alert_time bound the page — under newest-first ordering the oldest stamp IS how far back this read reached. Raise limit or narrow hours_back when truncated is true; widening hours_back cannot help. BY DEFAULT THIS READ EXCLUDES DISMISSED ALERTS — rows an operator acknowledged in the Alerts History tab. Dismissal says nothing about whether the alert fired or mattered, so an incident reconstruction that ignores it can miss the very critical someone already looked at: dismissed_excluded says whether the filter applied and dismissed_excluded_count is how many rows in the window it removed, and include_dismissed = true returns them, each labelled dismissed = true. On this edition an alert that was dismissed AFTER aging into the parquet archive is removed by the archive view itself and can be neither returned nor counted here. notification_type is the delivery disposition and is the ONLY field that says why a row did not deliver: 'email'/'webhook'/'email+webhook' delivered on that channel; 'tray' is this instance's own balloon notification, which every non-muted alert gets, so it is what most rows read here and it does NOT report the email or webhook outcome; 'failed' means a channel was attempted and came back unsuccessful, with send_error carrying the first failing channel's text; 'muted' means a mute rule suppressed it; 'digest' means the analysis finding was UNCORROBORATED (one fact in its chain, no matched co-fire check) and the corroboration gate kept it off email, the webhooks and the tray (#3712) — it is persisted, shown in Recommendations with a not-paged marker, and routing_reason on the row says which components decided; 'none' is a resolution row, which no channel applies to. Do NOT split the not-delivered rows on send_error: it is null whenever a cooldown or the per-metric repeat budget suppressed a send, on 'digest' rows, and on every row written before those dispositions existed. 'throttled' and 'folded' are recorded by the headless service; on this instance the tray channel answers first, so a cooldown-suppressed or folded send is stored as 'tray'. 'undelivered' is a retained legacy value that means throttled OR folded OR failed with nothing in the row to say which. severity is the row's tier — 'critical', 'warning', 'info' or 'resolution' — and severity_source says where it came from: 'fired' when the row persisted the tier the alert actually fired at (graded alerts such as Poison Wait, Volume Free Space and Database State fire Warning OR Critical by measurement), 'metric_name' when the row carries no tier and the metric's name is the only evidence (rows written before the tier was persisted, alerts whose severity is fixed per metric, and every resolution row). Do not infer a graded alert's tier from its name: a 'Poison Wait' row with severity 'warning' fired as a warning. routing (#3712) is the corroboration gate's decision for an 'Analysis: …' finding, one step upstream of delivery: 'page' when it earned a channel (two or more facts in its chain, a matched co-fire check, or one of the two by-construction stories), 'digest' when it was a lone uncorroborated fact; routing_reason names the components. Both are null on engine alerts and on analysis rows written before the gate existed.")] + [McpServerTool(Name = "get_alert_history"), Description("Gets alert history, NEWEST FIRST: each row FIRED; notification_type says whether it was DELIVERED, a separate question from DISMISSED (UI-acknowledged), excluded by default. THE PAGE IS BOUNDED BY limit, NOT hours_back: truncated says the window held more, and oldest/newest_returned_alert_time bound how far the page reached. An EMPTY page can mean no alerts fired, or that every alert here was dismissed: dismissed_excluded_count says which; include_dismissed = true returns them, labelled dismissed = true. A null send_error proves nothing about delivery.<>Gets recent alert history from the alert log, NEWEST FIRST. Shows what alerts fired, when, and whether email was sent successfully. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: alerts_returned is how many rows you got, truncated says the window held more than limit, and oldest_returned_alert_time / newest_returned_alert_time bound the page — under newest-first ordering the oldest stamp IS how far back this read reached. Raise limit or narrow hours_back when truncated is true; widening hours_back cannot help. BY DEFAULT THIS READ EXCLUDES DISMISSED ALERTS — rows an operator acknowledged in the Alerts History tab. Dismissal says nothing about whether the alert fired or mattered, so an incident reconstruction that ignores it can miss the very critical someone already looked at: dismissed_excluded says whether the filter applied and dismissed_excluded_count is how many rows in the window it removed, and include_dismissed = true returns them, each labelled dismissed = true. On this edition an alert that was dismissed AFTER aging into the parquet archive is removed by the archive view itself and can be neither returned nor counted here. notification_type is the delivery disposition and is the ONLY field that says why a row did not deliver: 'email'/'webhook'/'email+webhook' delivered on that channel; 'tray' is this instance's own balloon notification, which every non-muted alert gets, so it is what most rows read here and it does NOT report the email or webhook outcome; 'failed' means a channel was attempted and came back unsuccessful, with send_error carrying the first failing channel's text; 'muted' means a mute rule suppressed it; 'digest' means the analysis finding was UNCORROBORATED (one fact in its chain, no matched co-fire check) and the corroboration gate kept it off email, the webhooks and the tray — it is persisted, shown in Recommendations with a not-paged marker, and routing_reason on the row says which components decided; 'none' is a resolution row, which no channel applies to. Do NOT split the not-delivered rows on send_error: it is null whenever a cooldown or the per-metric repeat budget suppressed a send, on 'digest' rows, and on every row written before those dispositions existed. 'throttled' and 'folded' are recorded by the headless service; on this instance the tray channel answers first, so a cooldown-suppressed or folded send is stored as 'tray'. 'undelivered' is a retained legacy value that means throttled OR folded OR failed with nothing in the row to say which. severity is the row's tier — 'critical', 'warning', 'info' or 'resolution' — and severity_source says where it came from: 'fired' when the row persisted the tier the alert actually fired at (graded alerts such as Poison Wait, Volume Free Space and Database State fire Warning OR Critical by measurement), 'metric_name' when the row carries no tier and the metric's name is the only evidence (rows written before the tier was persisted, alerts whose severity is fixed per metric, and every resolution row). Do not infer a graded alert's tier from its name: a 'Poison Wait' row with severity 'warning' fired as a warning. routing is the corroboration gate's decision for an 'Analysis: …' finding, one step upstream of delivery: 'page' when it earned a channel (two or more facts in its chain, a matched co-fire check, or one of the two by-construction stories), 'digest' when it was a lone uncorroborated fact; routing_reason names the components. Both are null on engine alerts and on analysis rows written before the gate existed. Dismissal is an acknowledgement, not a verdict — a dismissed critical still fired — so set this when reconstructing an incident rather than triaging what is still open. Each row then carries dismissed so the two populations stay distinguishable.")] public static async Task GetAlertHistory( LocalDataService dataService, [Description("Hours of history. Default 24.")] int hours_back = 24, @@ -53,7 +53,7 @@ public static async Task GetAlertHistory( [Description(McpHelpers.AsOfDescription)] string? as_of = null, /* Appended after as_of: MCP invokes by name, and a trailing optional is the one position no existing positional C# caller can be re-bound by. Same convention as Darling's twin. */ - [Description("Include alerts an operator has dismissed in the Alerts History tab. Default false, which is the tab's own read. Dismissal is an acknowledgement, not a verdict — a dismissed critical still fired — so set this when reconstructing an incident rather than triaging what is still open. Each row then carries dismissed so the two populations stay distinguishable.")] bool include_dismissed = false) + [Description("Include alerts an operator has dismissed in the Alerts History tab. Default false, which is the tab's own read.")] bool include_dismissed = false) { try { From 8c19ecc8705073aaba4b525f23ee6b3306e62333 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:56:37 -0400 Subject: [PATCH 2/2] Pin the alerting family's new heads and tails (#3898) Refs #3898. Extends McpToolGuideHeads.Alerting.cs on both products with get_alert_history, set_mute_rule_enabled, create_mute_rule and set_notification_route_enabled: the roster used by the shared under-620-chars/pointer check, plus per-tool head and tail assertions following the file's existing pattern. No existing method edited. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M7r1CXmEcBmsrTV34CCLfF --- .../McpToolGuideHeads.Alerting.cs | 103 ++++++++++++++++++ Lite.Tests/McpToolGuideHeads.Alerting.cs | 34 ++++++ 2 files changed, 137 insertions(+) diff --git a/Darling/Darling.Tests/McpToolGuideHeads.Alerting.cs b/Darling/Darling.Tests/McpToolGuideHeads.Alerting.cs index 32f14b83bf..03996eb2ae 100644 --- a/Darling/Darling.Tests/McpToolGuideHeads.Alerting.cs +++ b/Darling/Darling.Tests/McpToolGuideHeads.Alerting.cs @@ -30,6 +30,10 @@ public sealed class McpToolGuideHeadsAlertingTests "get_notification_routes", "update_alert_settings", "update_mute_rule", + "get_alert_history", + "set_notification_route_enabled", + "create_mute_rule", + "set_mute_rule_enabled", ]; [Fact] @@ -149,4 +153,103 @@ public void DarlingTails_StillCarryTheUpdateToolsKnobsAndCautions() Assert.Contains("a whole-fleet silence", muteRuleTail, StringComparison.Ordinal); Assert.Contains("must match the alert rows' spelling EXACTLY", muteRuleTail, StringComparison.Ordinal); } + + /// #3898 lane L3c: get_alert_history's head states the fired/delivered/dismissed distinction (a row + /// existing means it FIRED; notification_type says whether it was DELIVERED; dismissed is a third, separate, + /// excluded-by-default axis), the page-bound-by-limit-not-hours_back trap, and the empty-vs-quiet hazard (an + /// empty page can mean no alerts fired OR that every alert was dismissed). Byte-identical on Lite (D6), + /// pinned again in Lite.Tests/McpToolGuideHeads.Alerting.cs. + [Fact] + public void GetAlertHistoryHead_StatesFiredVsDeliveredVsDismissedAndThePageBound() + { + var head = McpToolGuideTests.Served("get_alert_history").Served; + Assert.Contains("each row FIRED", head, StringComparison.Ordinal); + Assert.Contains("notification_type says whether it was DELIVERED", head, StringComparison.Ordinal); + Assert.Contains("DISMISSED (UI-acknowledged), excluded by default", head, StringComparison.Ordinal); + Assert.Contains("THE PAGE IS BOUNDED BY limit, NOT hours_back", head, StringComparison.Ordinal); + Assert.Contains("An EMPTY page can mean no alerts fired, or that every alert here was dismissed", head, StringComparison.Ordinal); + Assert.Contains("A null send_error proves nothing about delivery", head, StringComparison.Ordinal); + } + + /// #3898 lane L3c: set_notification_route_enabled's head states what it changes (shared alert + /// configuration), that it is reversible (unlike delete_notification_route), and the two safe-retry + /// outcomes. + [Fact] + public void SetNotificationRouteEnabledHead_StatesTheChangeAndReversibility() + { + var head = McpToolGuideTests.Served("set_notification_route_enabled").Served; + Assert.Contains("WITHOUT deleting it", head, StringComparison.Ordinal); + Assert.Contains("Changes shared alert configuration the service delivers on", head, StringComparison.Ordinal); + Assert.Contains("Reversible: re-enabling restores it exactly", head, StringComparison.Ordinal); + Assert.Contains("Returns updated (route as stored), unchanged", head, StringComparison.Ordinal); + Assert.Contains("Cannot author or re-point a route", head, StringComparison.Ordinal); + } + + /// #3898 lane L3c: create_mute_rule's head states the whole-fleet-silence hazard of a rule with no + /// scope fields, that there is no confirm step (it takes effect on the next collection sweep), and what it + /// returns for the follow-up delete/enable calls. + [Fact] + public void CreateMuteRuleHead_StatesTheEmptyRuleMutesEverythingHazard() + { + var head = McpToolGuideTests.Served("create_mute_rule").Served; + Assert.Contains("Changes shared alert configuration the service delivers on", head, StringComparison.Ordinal); + Assert.Contains("a rule with NO fields set matches and mutes EVERY alert across the whole fleet", head, StringComparison.Ordinal); + Assert.Contains("No confirm step", head, StringComparison.Ordinal); + Assert.Contains("Returns the stored rule with its generated id, for delete_mute_rule or set_mute_rule_enabled", head, StringComparison.Ordinal); + } + + /// #3898 lane L3c: set_mute_rule_enabled's head states it is the reversible alternative to + /// delete+create (which loses the id and resets the Stale Mute Rules age clock), and the two safe-retry + /// outcomes. + [Fact] + public void SetMuteRuleEnabledHead_StatesTheReversibleFlagAndTheStaleClock() + { + var head = McpToolGuideTests.Served("set_mute_rule_enabled").Served; + Assert.Contains("WITHOUT deleting it", head, StringComparison.Ordinal); + Assert.Contains("Changes shared alert configuration the service delivers on", head, StringComparison.Ordinal); + Assert.Contains("keeps its id, scope, reason and creation date", head, StringComparison.Ordinal); + Assert.Contains("unlike delete+create, which loses those and resets the Stale Mute Rules age clock", head, StringComparison.Ordinal); + Assert.Contains("Returns updated (rule as stored), unchanged", head, StringComparison.Ordinal); + } + + /// D9: load-bearing facts that moved off get_alert_history's head stay reachable in Darling's own + /// tail: the full notification_type taxonomy, the severity/severity_source distinction, and the route vs + /// routing distinction (#3598/#3712 refs stripped per D4, the facts kept). + [Fact] + public void DarlingGetAlertHistoryTail_StillCarriesTheNotificationTypeAndRoutingTaxonomy() + { + var tail = McpToolGuideTests.Served("get_alert_history").Tail!; + Assert.Contains("notification_type is the delivery disposition and is the ONLY field that says why a row did not deliver", tail, StringComparison.Ordinal); + Assert.Contains("severity_source says where it came from", tail, StringComparison.Ordinal); + Assert.Contains("route says WHERE the posts went:", tail, StringComparison.Ordinal); + Assert.Contains("routing is a DIFFERENT question from route:", tail, StringComparison.Ordinal); + Assert.Contains("To see what the digest carried", tail, StringComparison.Ordinal); + Assert.DoesNotContain("(#3598)", tail, StringComparison.Ordinal); + Assert.DoesNotContain("(#3712)", tail, StringComparison.Ordinal); + } + + /// D9/D2: the three write tools' tails still carry their full original reasoning, and + /// get_alert_history's include_dismissed overflow (D2: the parameter's own text stayed under 200 chars, the + /// rest moved to the tool's tail) is reachable there. + [Fact] + public void DarlingWriteToolTails_StillCarryTheOriginalReasoningAndTheMovedParameterOverflow() + { + var routeTail = McpToolGuideTests.Served("set_notification_route_enabled").Tail!; + Assert.Contains("the reversible form of", routeTail, StringComparison.Ordinal); + Assert.Contains("The running service picks the change up on its next collection sweep", routeTail, StringComparison.Ordinal); + Assert.Contains("destinations are bearer secrets and are set only in the Viewer's Settings window", routeTail, StringComparison.Ordinal); + + var createTail = McpToolGuideTests.Served("create_mute_rule").Tail!; + Assert.Contains("the same rules get_mute_rules lists and the Viewer's Manage Mute Rules surface writes", createTail, StringComparison.Ordinal); + Assert.Contains("expires_at is an optional ISO-8601 UTC timestamp", createTail, StringComparison.Ordinal); + Assert.Contains("a matching alert already mid-flight can still be delivered once", createTail, StringComparison.Ordinal); + + var enabledTail = McpToolGuideTests.Served("set_mute_rule_enabled").Tail!; + Assert.Contains("USE THIS rather than delete_mute_rule followed by create_mute_rule", enabledTail, StringComparison.Ordinal); + Assert.Contains("NEITHER direction changes created_at_utc", enabledTail, StringComparison.Ordinal); + + var historyTail = McpToolGuideTests.Served("get_alert_history").Tail!; + Assert.Contains("Dismissal is an acknowledgement, not a verdict", historyTail, StringComparison.Ordinal); + Assert.Contains("Each row then carries dismissed so the two populations stay distinguishable", historyTail, StringComparison.Ordinal); + } } diff --git a/Lite.Tests/McpToolGuideHeads.Alerting.cs b/Lite.Tests/McpToolGuideHeads.Alerting.cs index 39e13498b0..b14194ac5a 100644 --- a/Lite.Tests/McpToolGuideHeads.Alerting.cs +++ b/Lite.Tests/McpToolGuideHeads.Alerting.cs @@ -25,6 +25,7 @@ public sealed class McpToolGuideHeadsAlertingTests [ "get_alert_settings", "get_notification_routes", + "get_alert_history", ]; [Fact] @@ -83,4 +84,37 @@ public void LiteTails_StillCarryTheKnobsAndTheEditionGapsTheHeadCouldNotFit() Assert.Contains("routes_supported is false here", routesTail, StringComparison.Ordinal); Assert.Contains("The taxonomy is the shared", routesTail, StringComparison.Ordinal); } + + /// #3898 lane L3c: get_alert_history's head, byte-identical to Darling's (D6) — the + /// fired/delivered/dismissed distinction, the page-bound-by-limit-not-hours_back trap, and the + /// empty-vs-quiet hazard. See Darling.Tests/McpToolGuideHeads.Alerting.cs for the Darling-side copy of + /// this same assertion set. + [Fact] + public void GetAlertHistoryHead_StatesFiredVsDeliveredVsDismissedAndThePageBound() + { + var head = McpToolGuideTests.Served("get_alert_history").Served; + Assert.Contains("each row FIRED", head, StringComparison.Ordinal); + Assert.Contains("notification_type says whether it was DELIVERED", head, StringComparison.Ordinal); + Assert.Contains("DISMISSED (UI-acknowledged), excluded by default", head, StringComparison.Ordinal); + Assert.Contains("THE PAGE IS BOUNDED BY limit, NOT hours_back", head, StringComparison.Ordinal); + Assert.Contains("An EMPTY page can mean no alerts fired, or that every alert here was dismissed", head, StringComparison.Ordinal); + Assert.Contains("A null send_error proves nothing about delivery", head, StringComparison.Ordinal); + } + + /// D9: load-bearing facts that moved off get_alert_history's head stay reachable in Lite's own + /// tail: the 'tray' disposition (every non-muted alert gets one, and it does NOT report the email/webhook + /// outcome), the parquet-archive dismissal gap unique to this edition, and the severity/routing taxonomy + /// (#3712 refs stripped per D4, the facts kept). + [Fact] + public void LiteGetAlertHistoryTail_StillCarriesTheTrayDispositionAndArchiveGap() + { + var tail = McpToolGuideTests.Served("get_alert_history").Tail!; + Assert.Contains("'tray' is this instance's own balloon notification", tail, StringComparison.Ordinal); + Assert.Contains("it does NOT report the email or webhook outcome", tail, StringComparison.Ordinal); + Assert.Contains("On this edition an alert that was dismissed AFTER aging into the parquet archive is removed by the archive view itself", tail, StringComparison.Ordinal); + Assert.Contains("severity_source says where it came from", tail, StringComparison.Ordinal); + Assert.Contains("routing is the corroboration gate's decision for an 'Analysis", tail, StringComparison.Ordinal); + Assert.Contains("Dismissal is an acknowledgement, not a verdict", tail, StringComparison.Ordinal); + Assert.DoesNotContain("(#3712)", tail, StringComparison.Ordinal); + } }