-
Notifications
You must be signed in to change notification settings - Fork 91
Stop pretty-printing MCP tool results (#2350) #2355
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| /* | ||
| * 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 System.Text; | ||
| using Xunit; | ||
|
|
||
| namespace Darling.Tests; | ||
|
|
||
| /// <summary> | ||
| /// Substring assertions over serialized JSON that ignore LAYOUT (#2350). | ||
| /// | ||
| /// <para>A test written as <c>Assert.Contains("\"severity\": \"Critical\"", json)</c> reads as a claim about | ||
| /// content — this field serialized with this value — but is actually a claim about formatting, because the space | ||
| /// after the colon exists only under <c>WriteIndented</c>. When MCP tool results went compact, eighteen such | ||
| /// assertions failed across four files without a single one of the things they were testing having changed.</para> | ||
| /// | ||
| /// <para>These helpers normalize both sides by dropping whitespace that sits BETWEEN tokens while preserving | ||
| /// whitespace INSIDE strings, so <c>"a": "b c"</c> and <c>"a":"b c"</c> compare equal and the two-space value in | ||
| /// <c>"b c"</c> survives. The assertion then means what it always looked like it meant.</para> | ||
| /// | ||
| /// <para>Deliberately not a full JSON parse: these are substring assertions on purpose — they check a field | ||
| /// serialized a particular way (an enum as its string name rather than its ordinal, a null that stayed null) | ||
| /// without pinning the shape of the whole envelope around it.</para> | ||
| /// </summary> | ||
| internal static class JsonAssert | ||
| { | ||
| /// <summary>xUnit's argument order (expected first) so call sites read the same as the assertion they replace.</summary> | ||
| internal static void Contains(string expectedFragment, string json) | ||
| { | ||
| Assert.Contains(StripInsignificantWhitespace(expectedFragment), StripInsignificantWhitespace(json), StringComparison.Ordinal); | ||
| } | ||
|
|
||
| /// <inheritdoc cref="Contains"/> | ||
| internal static void DoesNotContain(string unexpectedFragment, string json) | ||
| { | ||
| Assert.DoesNotContain(StripInsignificantWhitespace(unexpectedFragment), StripInsignificantWhitespace(json), StringComparison.Ordinal); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Removes whitespace outside string literals. Tracks escaping so a <c>\"</c> inside a string does not end it | ||
| /// and a <c>\\</c> before a quote does not escape it — get that wrong and the parser falls out of the string, | ||
| /// starts stripping real spaces from values, and the assertion silently starts comparing something else. | ||
| /// </summary> | ||
| internal static string StripInsignificantWhitespace(string json) | ||
| { | ||
| if (string.IsNullOrEmpty(json)) | ||
| { | ||
| return json ?? string.Empty; | ||
| } | ||
|
|
||
| var builder = new StringBuilder(json.Length); | ||
| var inString = false; | ||
| var escaped = false; | ||
|
|
||
| foreach (var c in json) | ||
| { | ||
| if (inString) | ||
| { | ||
| builder.Append(c); | ||
|
|
||
| if (escaped) | ||
| { | ||
| escaped = false; | ||
| } | ||
| else if (c == '\\') | ||
| { | ||
| escaped = true; | ||
| } | ||
| else if (c == '"') | ||
| { | ||
| inString = false; | ||
| } | ||
|
|
||
| continue; | ||
| } | ||
|
|
||
| if (c == '"') | ||
| { | ||
| inString = true; | ||
| builder.Append(c); | ||
| continue; | ||
| } | ||
|
|
||
| if (c is ' ' or '\t' or '\r' or '\n') | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| builder.Append(c); | ||
| } | ||
|
|
||
| return builder.ToString(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,12 +57,12 @@ namespace PerformanceMonitor.Darling.Service.Mcp; | |
| internal static class DarlingAgReader | ||
| { | ||
| /// <summary>Shared serializer options — snake_case field names come from the DTOs' <c>[JsonPropertyName]</c> | ||
| /// attributes, severities serialize as their string names, and the output is indented (the MCP tool | ||
| /// convention). ONE options object so <c>/api/ag</c> and <c>get_ag_health</c> serialize the identical | ||
| /// shape.</summary> | ||
| /// attributes, severities serialize as their string names, and the output is COMPACT (#2350 - the MCP tool | ||
| /// convention, since the reader on both ends is a parser rather than a person). ONE options object so | ||
| /// <c>/api/ag</c> and <c>get_ag_health</c> serialize the identical shape.</summary> | ||
| public static readonly JsonSerializerOptions JsonOptions = new() | ||
| { | ||
| WriteIndented = true, | ||
| WriteIndented = false, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test-coverage gap, not a bug: this flip (and the matching one in Lite gets a regression test for exactly this boundary — Worth adding a Darling-side analog of |
||
| Converters = { new JsonStringEnumConverter() }, | ||
| }; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice catch on the layout-vs-content conflation with
JsonAssert, but the fix looks one-sided:JsonAssert.Contains/DoesNotContainstrip whitespace from both the fragment and the actual JSON before comparing. That means every assertion in this file (andDarlingAgReaderTests.cs) now passes identically whetherDarlingFleetReader.JsonOptions/DarlingAgReader.JsonOptionsis indented or compact — they no longer provide any signal on the very property this PR flips (WriteIndented).Contrast with
Lite.Tests/McpOutputCompactionTests.cs, which pins the sharedMcpHelpers.JsonOptions.WriteIndented == falsedirectly plus a "no layout whitespace at all" check.DarlingAgReader.JsonOptionsandDarlingFleetReader.JsonOptionsgot the identicalWriteIndented = falseflip in this same PR but have no equivalent pin — someone flipping either back totrue(e.g. "make the/api/*output readable") would break nothing here.Worth adding a small
Assert.False(DarlingFleetReader.JsonOptions.WriteIndented)/ same forDarlingAgReaderso the two Darling-only readers get the same regression coverage as the Lite/Common path.