Repository navigation
MCP write tools report what happened: every server you add lands in a counter, a partial name cannot delete the wrong server, an omitted field is never a clear, and a mute that matched nothing says so (#3541 A14) - #3615
Conversation
… counter, a partial name cannot delete the wrong server, an omitted field is never a clear, and a mute that matched nothing says so (#3541 A14) add_servers: the per-row status "collides" (#2280) was counted in no summary counter, so a batch with a collided server summarised as failed: 0 and the server went silently unmonitored. Every status now maps to exactly one counter through CounterOfStatus (added / skipped / collided / failed), the envelope carries requested, the four counters sum to it by construction, and the aggregator throws on a status the map does not know. Statuses are constants. remove_server: the read resolver's first-wins partial match deleted whichever sibling sorted first. The write now honors an exact match, or a partial ONLY when unique; an ambiguous name deletes nothing and returns status "ambiguous" with the candidates named (matched_by exact|partial on success). Instructions: the add_servers row still denied the Entra modes #3484 accepted; it now names ServicePrincipal / ManagedIdentity as accepted and the interactive trio as invalid, plus engine / port and the new counters. update_custom_view / update_custom_alert_rule: one write vocabulary for the optional description - omitted = unchanged, empty string = cleared - through a single shared helper (ResolveOptionalText). The view tool used to write an omitted description as NULL; neither tool offered a clear. mute_analysis_finding (both SKUs): the response carries registered and matched_now (stored findings in the mute's scope carrying the hash); status is "muted" or "muted_unmatched", and Darling's swallowed INSERT failure now surfaces as status "error" instead of "muted". The store gains a hash count read (CountStoredFindingsAsync) on both FindingStores; PgFindingStore.MuteStoryAsync returns whether the row landed. A blank hash is refused. Rider (#3594 residual): /api/read/get_alert_history dispatches and advertises include_dismissed. Tests: counters census + sum, unknown-status throw, construction-site literal census, ResolveForRemoval truth table, Entra parser-vs-table pin, description vocabulary pins and cross-tool census, web catalog + dispatch pin, live extensions for the collision batch / ambiguous removal / omitted-vs-cleared description on both update tools / matched_now on Darling, and a new Lite DuckDB class for the mute disclosure with a negative server id.
There was a problem hiding this comment.
LGTM — reviewed the five write-contract fixes (add_servers counters, remove_server ambiguity handling, update_custom_view/update_custom_alert_rule description vocabulary, mute_analysis_finding disclosure, and the get_alert_history web rider) for correctness, Lite/Darling parity, security, and performance. All parameterized SQL, no secret leakage, counter/status maps are exhaustive and throw on drift, and the mute/description fixes are correctly mirrored across both SKUs where both SKUs have the surface (fleet-admin and custom-view/alert tools are intentionally Darling-only). No issues found.
…CountStoredFindingsAsync, the #3541 A14 matched_now read) TheWritePathsTakeAReadLockOnPurpose_AndSayWhy pins the count of _duckDb.AcquireReadLock( sites so a new lock site is a decision made against the #2455 reason rather than a tidy-up. The new count read takes the READ lock (it is a read; the exclusion it needs is against maintenance only), so the pin moves 6 → 7 with the site named beside it. First CI run's only failure.
…k, maintenance-only exclusion, #2455)
There was a problem hiding this comment.
LGTM — reviewed the add_servers counter/collides mapping, remove_server exact/unique-partial resolution, mute_analysis_finding registered/matched_now disclosure, and the shared ResolveOptionalText vocabulary across both Lite and Darling. Logic is correct, edge cases (negative server_id, ambiguous names, omitted-vs-empty description) are handled, and the two apps stay in parity.
… idiom (#3653 Dashboard mirrors) (#3658) - get_blocking / get_deadlocks / get_alert_history: page counts stop posing as totals (*_returned, truncated, limit_applied_by_reader) - #3594's class - Poison Wait: accumulated over the ten-minute window, graded on the shared PoisonWaitEvaluator bars, clear needs an observation - #3593's class - mute_analysis_finding: registered / matched_now / muted_unmatched - #3615's class - CollectorSeverity (0,0) online -> Unknown, '--' - #3635's class - cntr_value_per_second recomputed as a fraction on the read - #3540 A11 - SqlServerAnomalyDetector lab-box markers replaced with what #3616 measured (comment-only)
Every MCP write on this surface now reports what it DID, not what it was asked
This is the A14 "Write contracts" item of the payload-contract campaign (#3541), plus the one-line
/api/read/get_alert_historyrider parked on the issue by the A3 lane. PARTIAL for #3541 — A14 ticked, the rider done; the rest of the checklist is other lanes.The rule under test is the contract's ninth: writes report what happened. Five write tools across both SKUs failed it in five different ways, and the common shape is worth naming because it is the shape every fix here takes: each tool returned the outcome it intended —
muted,failed: 0,removed, the stored row — and never checked that outcome against the input it was handed. An agent that cannot see the code reads those payloads as facts.The five defects and what the tools say now
1.
add_servers: a collided server was counted in no counter. #2280 added a fifth per-row status,collides(the probe reached a database another registration already covers; the entry is refused). The summary was three ad-hocCount(...)filters written before #2280, naming four statuses. A batch of three with one collision summarised as{added: 2, skipped: 0, failed: 0}— a clean run, with the collided server silently unmonitored. Now:{requested, added, skipped, collided, failed, results}, where every per-row status is mapped to exactly one counter byCounterOfStatus, the four counters sum torequestedby construction, and the aggregator throws on a status the map does not know rather than dropping it. Statuses became constants (AddStatus) so a sixth cannot be introduced as a bare literal.collidedis its own counter, not a kind offailed, because the remedy differs: a failed entry is retried after fixing the connection; a collided one must not be retried as sent.2. The instruction table still denied Entra. #3484 accepted
ServicePrincipalandManagedIdentity; theadd_serversrow inDarlingMcpInstructionswent on saying "Entra/MFA/Service-Principal/Managed-Identity auth isinvalid(Windows/SQL only)" for a release. The row now matches the parser exactly — the two non-interactive modes accepted, the interactive trio (MFA / device-code / default-credential)invalid— and picks upengine/portand the new counters. A pin derives the accepted set fromParseRequestand holds the table to it.3.
remove_server: a partial name could delete the wrong server. It resolved through the read resolver, whose partial match is first-wins ordered by storage name. For a read that is a convenience — the payload names the server it landed on and the caller re-asks. On a DELETE the payload is the damage: a fragment two siblings contain removed whichever sorted first. The write now reads the same registry rows but with its own rule: exact match (storage or display name) first; a partial match honored only when unique; anything else returns{status: "ambiguous", matched_by, candidates: [{server, display_name}]}and deletes nothing. Exact matches are collected, not first-taken —display_nameis not unique, so two registrations sharing one are ambiguous too. Partial matching survives because the description has promised it since the tool shipped and a unique partial is something the tool can honor; what it cannot honor is a choice. Success now carriesmatched_by: "exact" | "partial".4.
update_custom_viewcleared an omitted description;update_custom_alert_rulekept it. Two tools, one field, opposite rules — and neither offered a way to clear at all (the rule tool's parameter said "this cannot clear it"). Over MCP an omitted argument and an explicitnullboth arrive as C#null, so null must mean unchanged (a caller sending{enabled: false}to pause a rule is not asking to lose its description), which leaves the empty string as the only in-band clear. One helper,ResolveOptionalText(omitted → current, empty/whitespace → NULL, text → text), now sits behind both tools; the view tool reads the row first so it has a current value to keep. Both descriptions and both parameter descriptions state the vocabulary and name each other.5.
mute_analysis_findingreturnedmutedfor any hash — both SKUs. A mistyped hash, a hash from another store, a hash whose INSERT Darling's store swallowed and logged: all{status: "muted"}. The registry is a pattern registry (no row references a finding; the filter phase consults it by hash on every pass), so a hash matching nothing today is a legitimate registration — the pattern may return after retention purged its history — and the most likely shape of a typo. So the count is a disclosure, not a gate: the response now carriesregisteredandmatched_now(stored findings in the mute's scope carrying that hash, counted after the write so both are read against one moment), withstatus: "muted"when ≥ 1 and"muted_unmatched"when 0, plus anotesaying what to check. On Darling,PgFindingStore.MuteStoryAsyncnow returns whether the row landed (the swallow-and-log stays for the viewer's button; the caller learns which of the two things happened), and a failed write surfaces asstatus: "error"instead of"muted". A blank hash is refused asinvalidon both SKUs. BothFindingStores gainCountStoredFindingsAsync(int? serverId, string hash), a plain equality on the existingidx_analysis_findings_hash(plusserver_idwhen scoped) — sub-second by shape on the retained population, and off the alert path regardless.Rider.
/api/read/get_alert_historydid not pass or advertiseinclude_dismissed(added to the MCP tool by #3594), so a web/API caller receiveddismissed_excluded_count— "N rows were hidden" — with no wire key to un-hide them. The dispatch passes it by name throughQueryBoollike every other optional boolean, and the catalog entry advertises it as an optional bool defaulting tofalse.Why these shapes, and what was rejected
Count(...)filter: the defect was a status added after the counters were written. A filter list has to be remembered; a map with a throw and a reflection census does not.remove_server: exact-only would break the documented contract and refuse names the tool can honor unambiguously. The contract rule is "refuse what you cannot honor," and a unique fragment is honorable.ResolveOptionalTextlives on the alert-rule tool (the newer contract's home) and the view tool calls it; a census pins both call sites to the one method.Blast radius
add_serversenvelope gainsrequestedandcollided; existing keys unchanged. Per-row status strings unchanged (collidesstayscollides— a public contract already).remove_servergainsmatched_byon success and a newambiguousstatus. A caller relying on first-wins partial deletion now gets a refusal — which is the point.mute_analysis_findinggainsregistered,matched_now,note;statusismuted_unmatchedwhere it used to saymutedfor a hash with no stored finding. The existing Darling live test's all-servers case assertedmutedfor exactly that situation; it now asserts the truthful status.PgFindingStore.MuteStoryAsync→Task<bool>,DarlingAnalysisService.MuteFindingAsync→Task<bool>. Existingawaitcallers (the viewer'sMuteFindingAsync) compile unchanged. Lite's twins keepTaskbecause Lite's store throws instead of swallowing; the caller-visible contract is the same.update_custom_viewperforms one primary-keySELECTbefore itsUPDATE. No store SQL changed on either tool; no schema, no rungs.DarlingServerResolver) is untouched; every read tool resolves exactly as before.What this does NOT do
deprecated/Dashboardtwin ofmute_analysis_finding(same defect exists there — reported to the coordinator as out-of-lane per the file boundary).collidesstatus or theskippedcounter.remove_serverresolves against the registry (a server added but never connected is still absent fromservers, as before).Test plan
All new tests are compile-verified here (zero warnings, all four projects) and first executed in CI — the test projects are
net10.0-windows. Because CI is the first run, every pure-logic and source-text pin was additionally executed through a throwawaynet10.0console harness against the built assemblies, and every live path through a throwaway PG18/TimescaleDB container (Darling) and a real DuckDB file (Lite): all green, containers and harnesses torn down. That harness caught one shipping defect before commit: the count's scope test wasserverId > 0, and aserver_idis an FNV hash cast toint, so half of all real ids are negative — a> 0test counted those servers' scoped mutes fleet-wide. It is nowis not (null or 0), and the Lite test's second server id is negative on purpose.New, ungated (Darling.Tests):
EveryAddStatus_HasExactlyOneSummaryCounter_AndNoCounterIsUnnamed— status set == map key set, every counter named by the envelope,collides → collided.Aggregate_CountsEveryResultOnce_AndTheCountersSumToRequested— all five statuses × 2; counters, sum, order.Aggregate_RefusesAStatusNoCounterAccountsFor— unknown status throws, names it.EveryServerResultConstruction_NamesItsStatusThroughAddStatus— source census: no bare status literal at any result site.AddServersDescription_NamesEveryCounter_TheSumRule_AndTheCollidesStatus;InstructionsTable_MatchesTheAuthModesTheParserAccepts(accepted set derived fromParseRequest; both denial spellings absent).ResolveForRemoval_*(6): exact storage/display, ambiguous fragment → both, unique partial, exact beats partial, shared display name ambiguous, no match;RemoveServerDescription_PromisesTheAmbiguousRefusal.ResolveOptionalText_OmittedKeeps_EmptyClears_TextReplaces(truth table);BothUpdateTools_RouteDescriptionThroughTheSharedVocabulary(cross-tool source census; helper declared once);BothUpdateTools_DescribeTheSameDescriptionVocabulary.MuteAnalysisFinding_Description_NamesRegistered_MatchedNow_AndTheUnmatchedStatus.WebRead_GetAlertHistory_AdvertisesAndDispatchesIncludeDismissed— catalog param (bool, optional, default false) + dispatch source pin.New, Lite.Tests (DuckDB, runs in CI):
McpMuteReportsWhatItMatchedTests— matched (2) →muted; never-seen →muted_unmatched, still registered; scope (server vs fleet, negative server id); blank hash invalid + no row;CountStoredFindingsAsyncscoping incl. null and 0; description tokens.Extended, live (DARLING_TEST_PG): server-admin round-trip adds the collision batch (
requested 3 / added 1 / skipped 1 / collided 1 / failed 0, store agrees), ambiguous fragment refusal with both candidates and nothing deleted, unique partial removal, exact removal withmatched_by; custom-view round-trip adds omitted-keeps (v3) and empty-clears (v4); custom-alert-rule round-trip adds omitted-keeps, empty-clears, stays-cleared; MCP-tools round-trip assertsmatched_now: 2on the planted chain,muted_unmatchedon a never-seen hash (row present), and the truthful all-servers status.Two existing assertions changed: the all-servers mute assertion (
muted→muted_unmatched, because no stored row carries that hash — the old assertion was asserting the lie), and one version literal in the alert-rule round-trip shifted for the two inserted steps.Which component(s) does this affect?
How was this tested?
Compile-only on macOS (all four projects, zero warnings); pure-logic, source-text and live paths executed through throwaway harnesses against the built assemblies, a throwaway PG18/TimescaleDB container and a real DuckDB file (see Test plan). No SQL Server is touched by any of these tools' code paths beyond the stubbed probe seam. CI is the first execution of the test projects themselves.
Checklist
dotnet build -c Debug)