Skip to content

Stop pretty-printing MCP tool results (#2350) - #2355

Merged
erikdarlingdata merged 2 commits into
devfrom
fix/2350-compact-mcp-json
Aug 19, 2026
Merged

Stop pretty-printing MCP tool results (#2350)#2355
erikdarlingdata merged 2 commits into
devfrom
fix/2350-compact-mcp-json

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #2350.

One property on the shared McpHelpers.JsonOptions in Common — 78 call sites across both SKUs' MCP surfaces already route through it — plus the two readers (DarlingAgReader, DarlingFleetReader) that carry their own options so the /api/* endpoints and the MCP tools keep serializing an identical shape.

The saving, stated honestly

Payload-shaped: 23% of the bytes on a 15-field record array, 36% on a narrow one. The token saving is smaller than the byte saving — BPE tokenizers pack runs of spaces efficiently, so anyone quoting these numbers as token savings will be wrong. The argument is that it costs nothing and compounds where it matters: tool results are the bulk of what fills an agent's context during a real incident, and the fleet-wide reads are the widest results we return.

Scope was the whole risk

Flipping WriteIndented on something that turns out to write a config file would make servers.json a single unreadable line — the kind of damage nobody notices until they open the file by hand at a bad moment. So I checked before changing:

  • McpHelpers.JsonOptions is MCP-output only. Of 78 usage files, exactly one sits outside an Mcp/ folder (McpPlanAnalysisFormatter, still an MCP formatter), and nothing writes a file with it.
  • ServerManager, ProfileManager and ScheduleManager carry their own WriteIndented = true and are untouched.

Nothing depended on the layout

  • No test asserts on serialized MCP output containing newlines or indentation (the two \n hits in the suites are an axis tick label and a hand-written JSON literal, both unrelated).
  • The 24 test files that read tool JSON use a parser, which is whitespace-insensitive.
  • The tests that touch this options object assert field names — there is no naming policy on it, so snake_case comes from [JsonPropertyName] — not layout.

Tests

McpOutputCompactionTests pins the flag, the observable consequence (no \n, no \r, no indent run), that compaction changes layout only and not content (same parsed values, and genuinely smaller), and the boundary — those three config writers still indent, asserted structurally the way this repo pins invariants it cannot compile.

Verification

Builds clean, 0 warnings. Suites target net10.0-windows so they cannot run on macOS; every assertion in the new test was exercised first in a throwaway net10.0 harness and passes.

Provenance

Found while reviewing #2286, which proposed a third-party wire format and benchmarked it against our indented output — so part of the win it reported was a serializer flag rather than the format. Credit to @blackwell-systems for the measurement that surfaced this; this half carries no new dependency and stands on its own.

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown

Reviewed. This is a well-scoped change — flips WriteIndented to false on McpHelpers.JsonOptions (Common, shared by both SKUs) plus the two Darling-only readers (DarlingAgReader, DarlingFleetReader) that carry their own copy for the /api/* twins.

Checks I ran to try to break the stated scope claims:

  • Confirmed those two readers are the only other JsonSerializerOptions instances in either app's Mcp/ folders — everything else (79 call sites) routes through the shared McpHelpers.JsonOptions, so there's no orphaned third copy that got missed.
  • Confirmed Lite has no AG/fleet reader counterpart to flip (Lite is single-server; those concepts are Darling-only), so this isn't a Lite/Darling parity gap — it's a Common-level change that both SKUs pick up for free.
  • Confirmed the three config-writer files the new ConfigFileWriters_StayIndented theory pins (ServerManager.cs, ProfileManager.cs, ScheduleManager.cs) still contain WriteIndented = true, so the boundary test isn't asserting something already false.
  • Grepped existing Darling.Tests/Lite.Tests for assertions on serialized-output indentation/newlines that this could silently break — found none; the one \n hit in McpSchemaCompatTests.cs is an unrelated error-message string, matching what the PR description claims.
  • No user-input handling, secrets, file/network/process surface touched — this is a pure serializer-flag flip, so no security or correctness concerns there.

No findings to flag. Nice, honest PR description too (the "token savings are smaller than byte savings" caveat is the kind of detail that's easy to oversell and wasn't).

@erikdarlingdata

Copy link
Copy Markdown
Owner Author

CI caught a gap in my own pre-change check, and the fix is worth describing because the first instinct would have been wrong.

What I missed. I verified "nothing depends on the layout" by grepping for tests asserting on serialized output containing newlines. Eighteen assertions depended on the space after the colon instead — "severity": "Critical" only exists under WriteIndented — so four test classes failed without a single thing they were testing having changed. My check was narrower than my claim.

Why I did not just retype them compact. Every one of those assertions reads as a claim about content: this field serialized with this value, this enum came out as its string name rather than its ordinal, this null stayed null. They were written as claims about formatting. Flipping ": " to ":" would have made them pass while leaving exactly the same trap armed for whoever changes the formatting next.

So JsonAssert.Contains/DoesNotContain normalize both sides — dropping whitespace between tokens, preserving whitespace inside strings, so "a": "b c" and "a":"b c" compare equal and the two-space value in "b c" survives. Escaping is tracked, because if a \" inside a string is read as the end of it the scan falls out, starts stripping real spaces from values, and the assertion silently starts comparing something else. That is verified separately: escaped quote, escaped backslash immediately before a closing quote, inner double-spaces, newline+indent, and empty all pass.

Still substring assertions rather than a full parse, on purpose — they pin one field's serialization without pinning the shape of the envelope around it.

Four format-coupled assertions are deliberately left alone: the exported darling.json, the network config editor, a stored command result, and a static options constant. Those are config files people read and one literal — none of them MCP output — and CI passed all four, which is the evidence that the scope of the change really is what I claimed.

public static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
WriteIndented = false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 DarlingFleetReader.cs:50) has no Darling-side pin.

Lite gets a regression test for exactly this boundary — Lite.Tests/McpOutputCompactionTests.cs asserts McpHelpers.JsonOptions.WriteIndented == false and, in the other direction, that ServerManager/ProfileManager/ScheduleManager source still contains WriteIndented = true. Darling has no counterpart for DarlingAgReader/DarlingFleetReader, and the existing tests that were touched here (DarlingAgReaderTests, DarlingFleetReaderTests) don't fill the gap: they now route through the new JsonAssert.Contains/DoesNotContain, which strips whitespace outside string literals from both sides before comparing. That makes them pass identically whether WriteIndented is true or false here — so nothing in the Darling suite would fail if this line were reverted to true, and nothing would catch an accidental compaction of the Viewer's own config writers (ViewerServerStore, ViewerProfileStore, ViewerAlertStateService, ViewerAppSettings, ViewerPreferences, all still WriteIndented = true).

Worth adding a Darling-side analog of McpOutputCompactionTests (direct assert on DarlingAgReader.JsonOptions.WriteIndented / DarlingFleetReader.JsonOptions.WriteIndented, plus a source pin that the Viewer writers stay indented) to close the same regression risk this PR just closed for Lite.

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown

Reviewed. This is a well-scoped, well-audited change — no T-SQL touched, so the collector-style conventions don't apply here. The scoping claims in the PR description check out against the actual tree:

  • All 79 production files using McpHelpers.JsonOptions sit under an Mcp/ folder or are the one documented exception (PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs); none write to disk.
  • Every file-writing config store I could find (Lite/Services/{ServerManager,ProfileManager,ScheduleManager,AlertStateService}.cs, Darling/.../Viewer{ServerStore,ProfileStore,AlertStateService,AppSettings,Preferences}.cs, plus the deprecated Dashboard stores) keeps its own separate, still-indented JsonSerializerOptions — none of them route through the object that got flipped.
  • The frontend JS (wwwroot/js/pages/{ag,fleet}.js etc.) reads these endpoints with fetch()/JSON.parse, so compaction is a non-issue there.
  • JsonAssert.StripInsignificantWhitespace correctly tracks string boundaries and \"/\\ escaping, so it won't fall out of a string and start stripping real spaces from a value.
  • Swept the other Darling test files with colon-quoted assertions (DarlingCliCommandsTests, DarlingExportViewerConfigTests, DarlingNetworkConfigEditorTests, RollupBackfillTests, ViewerWave2Tests) — all of those assert against hand-written literals or the Viewer's own indented serializer, not McpHelpers/DarlingAgReader/DarlingFleetReader, so none were missed by the 4-file fixup in the second commit.

One gap flagged inline: Lite gets a regression test pinning the compact/indented boundary in both directions (Lite.Tests/McpOutputCompactionTests.cs), but Darling's two reader classes that got the same flip (DarlingAgReader, DarlingFleetReader) have no equivalent pin — and the existing tests touching them were rewritten to use a whitespace-insensitive JsonAssert, so they'd pass unchanged even if WriteIndented were reverted to true on either one.

erikdarlingdata and others added 2 commits August 19, 2026 12:17
The only consumer of an MCP tool result is a language model, and indentation buys a model
nothing (#2350). It was one property on one shared object in Common, so both SKUs move
together: 78 call sites across the Darling and Lite MCP surfaces already route through
McpHelpers.JsonOptions. The two readers that keep their own options for the /api/* twins
get the same treatment, so the web endpoint and the tool still serialize an identical shape.

The saving is payload-shaped and should not be oversold: 23% of the BYTES on a 15-field
record array, 36% on a narrow one, and the TOKEN saving is smaller than either because BPE
tokenizers pack runs of spaces efficiently. It costs nothing, which is the argument.

Scope was the whole risk here, not mechanism. The config files people open and hand-edit
keep indenting -- ServerManager, ProfileManager and ScheduleManager carry their own options
and are untouched -- because flipping this on a file writer would turn servers.json into
one unreadable line, which nobody notices until an awkward moment. The new test pins both
directions: the tool output carries no layout whitespace, and those three writers still say
WriteIndented = true.

Verified nothing depended on the layout first: no test asserts on serialized MCP output
containing newlines, the 24 files that parse tool JSON use a parser, and the tests that
touch this options object assert field NAMES rather than shape.

Found while reviewing #2286, which benchmarked a third-party wire format against our
indented output -- so some of the win it reported was a serializer flag. Credit to
@blackwell-systems for the measurement that surfaced it; this half carries no dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI caught what my pre-change check missed. I grepped for tests asserting on serialized
output containing NEWLINES and concluded nothing depended on the layout. Eighteen
assertions depended on the space after the colon instead -- "severity": "Critical" only
exists under WriteIndented -- and four test classes failed without a single thing they
were testing having changed.

Those assertions read as claims about content (this field serialized with this value, an
enum as its string name rather than its ordinal, a null that stayed null) but were written
as claims about formatting. So the fix is to make them mean what they looked like they
meant, not to retype the literals in compact form -- which would leave the same trap armed
for whoever changes the formatting next.

JsonAssert.Contains/DoesNotContain normalize both sides by dropping whitespace BETWEEN
tokens while preserving whitespace INSIDE strings, so "a": "b c" and "a":"b c" compare
equal and the two-space value in "b c" survives. Escaping is tracked so a \" inside a
string does not end it and a \\ before a quote does not escape it; get that wrong and the
scan falls out of the string, starts stripping real spaces from values, and the assertion
silently compares something else.

Deliberately still substring assertions rather than a full parse: they check that one
field serialized a particular way without pinning the shape of the envelope around it.

Four format-coupled assertions elsewhere are deliberately untouched -- exported
darling.json, the network config editor, a stored command result and a static options
constant. Those are config files people read and a literal, none of them MCP output, and
CI passed them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata force-pushed the fix/2350-compact-mcp-json branch from 9b54b6b to b304a86 Compare August 19, 2026 12:17
Assert.Contains("\"band\": \"Critical\"", json, StringComparison.Ordinal);
Assert.Contains("\"cpu_severity\": \"Critical\"", json, StringComparison.Ordinal);
Assert.Contains("\"threads_severity\": \"Unknown\"", json, StringComparison.Ordinal);
JsonAssert.Contains("\"band\": \"Critical\"", json);

Copy link
Copy Markdown

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/DoesNotContain strip whitespace from both the fragment and the actual JSON before comparing. That means every assertion in this file (and DarlingAgReaderTests.cs) now passes identically whether DarlingFleetReader.JsonOptions/DarlingAgReader.JsonOptions is 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 shared McpHelpers.JsonOptions.WriteIndented == false directly plus a "no layout whitespace at all" check. DarlingAgReader.JsonOptions and DarlingFleetReader.JsonOptions got the identical WriteIndented = false flip in this same PR but have no equivalent pin — someone flipping either back to true (e.g. "make the /api/* output readable") would break nothing here.

Worth adding a small Assert.False(DarlingFleetReader.JsonOptions.WriteIndented) / same for DarlingAgReader so the two Darling-only readers get the same regression coverage as the Lite/Common path.

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown

Review summary

This is a narrow, well-contained change (one property on the shared McpHelpers.JsonOptions, plus the two Darling-only readers that carry their own options for the /api/* twins). Verified:

  • Lite/Darling parity: Both SKUs' MCP tools route through McpHelpers.JsonOptions from PerformanceMonitor.Common, so the single flip covers both correctly. The two readers with their own options (DarlingAgReader, DarlingFleetReader) were both updated in lockstep — no drift there. Lite has no /api/* web-endpoint twins, so it doesn't need an equivalent pair.
  • Config-file writers correctly excluded: ServerManager, ProfileManager, ScheduleManager (Lite) and ViewerServerStore, ViewerProfileStore, ViewerAppSettings, ViewerPreferences, ViewerAlertStateService (Darling) all keep their own independent WriteIndented = true options, untouched by this change — correct, since those are hand-edited files.
  • Test migration completeness: searched the whole tree for other whitespace-sensitive Assert.Contains("\"key\": value", ...) style assertions that might have been missed by the JsonAssert migration. Found a few more matches (DarlingCommandExecutorTests.cs, RollupBackfillTests.cs, DarlingExportViewerConfigTests.cs, DarlingNetworkConfigEditorTests.cs) but traced each one back to its source — they're all either raw JsonSerializer.Serialize with default (already-compact) options, hardcoded string constants, or hand-edited config text unrelated to McpHelpers.JsonOptions/the two Darling readers. None of them needed the JsonAssert treatment; the PR's claimed scope ("four files, eighteen assertions") checks out.
  • JsonAssert.StripInsignificantWhitespace: walked through the escape/quote-tracking logic by hand — correctly handles \" inside strings and \\ before a quote, no off-by-one issues found.

One gap worth a look — left as an inline comment on DarlingFleetReaderTests.cs: the new JsonAssert helper strips whitespace from both sides of the comparison, so the migrated Darling assertions no longer have any signal on the WriteIndented property itself (they'd pass identically indented or compact). Lite's new McpOutputCompactionTests.cs pins McpHelpers.JsonOptions.WriteIndented == false directly; the two Darling readers that got the identical flip have no equivalent regression pin.

No correctness, security, or performance concerns — this is JSON output formatting only, no new input handling or SQL surface.

@erikdarlingdata
erikdarlingdata merged commit 7330e03 into dev Aug 19, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant