From 03d8faf3553b4288c9175881923592b8d840b3de Mon Sep 17 00:00:00 2001 From: Dayna Blackwell Date: Fri, 4 Sep 2026 05:37:58 -0700 Subject: [PATCH] Add opt-in GCF output for the embedded MCP server (PLANVIEWER_OUTPUT_FORMAT=gcf) --- README.md | 4 + src/PlanViewer.App/Mcp/GcfCallToolFilter.cs | 44 ++++ src/PlanViewer.App/Mcp/GcfOutput.cs | 219 +++++++++++++++++ src/PlanViewer.App/Mcp/McpHostService.cs | 5 +- src/PlanViewer.App/PlanViewer.App.csproj | 1 + .../GcfCallToolFilterTests.cs | 86 +++++++ tests/PlanViewer.Core.Tests/GcfOutputTests.cs | 224 ++++++++++++++++++ .../PlanViewer.Core.Tests/GcfPipelineTests.cs | 107 +++++++++ 8 files changed, 689 insertions(+), 1 deletion(-) create mode 100644 src/PlanViewer.App/Mcp/GcfCallToolFilter.cs create mode 100644 src/PlanViewer.App/Mcp/GcfOutput.cs create mode 100644 tests/PlanViewer.Core.Tests/GcfCallToolFilterTests.cs create mode 100644 tests/PlanViewer.Core.Tests/GcfOutputTests.cs create mode 100644 tests/PlanViewer.Core.Tests/GcfPipelineTests.cs diff --git a/README.md b/README.md index b8eafcd8..d93af5a0 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,10 @@ Ask Claude Code to analyze loaded plans, identify warnings, suggest indexes, and ![MCP Integration](screenshots/MCP%20Integration.png) +#### Compact output (GCF) + +Set `PLANVIEWER_OUTPUT_FORMAT=gcf` to have the MCP server return tool results as [GCF](https://gcformat.com) instead of JSON. GCF factors the repeated field names of record-heavy results (Query Store plan lists, connection and statement listings) into a single header, so a model spends roughly a third fewer tokens reading them. The substitution is opt-in and conservative: a result is re-encoded only when the wire is both smaller than the JSON and decodes back to the same value, so anything it cannot losslessly shrink is returned as the original JSON. + ## What It Does Feed it a query plan and it tells you what's wrong: diff --git a/src/PlanViewer.App/Mcp/GcfCallToolFilter.cs b/src/PlanViewer.App/Mcp/GcfCallToolFilter.cs new file mode 100644 index 00000000..63579244 --- /dev/null +++ b/src/PlanViewer.App/Mcp/GcfCallToolFilter.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace PlanViewer.App.Mcp; + +// A single call-tool filter that offers each tool's result as GCF instead of JSON when +// PLANVIEWER_OUTPUT_FORMAT=gcf. Registered once in McpHostService, so it covers every tool +// with no per-tool changes. The re-encode is conservative (never larger, never lossy, see +// GcfOutput); anything it cannot faithfully shrink is returned as the original JSON. +public static class GcfCallToolFilter +{ + // The filter: run the tool, then transform its result. A filter is `next => handler`. + public static McpRequestFilter Instance => + next => async (request, cancellationToken) => Transform(await next(request, cancellationToken)); + + // Replaces a single JSON text-content block with its GCF wire when GCF is enabled and + // the wire is smaller and lossless; otherwise returns the result unchanged. Exposed for + // testing. StructuredContent (if a tool sets it) is left untouched. + public static CallToolResult Transform(CallToolResult result) + { + if (!GcfOutput.Enabled || result.Content == null || result.IsError == true) + return result; + + // Only a lone text block is re-encoded, so an image or other block sent alongside + // it is never dropped. + if (result.Content.Count != 1 || result.Content[0] is not TextContentBlock text) + return result; + + var wire = GcfOutput.TryEncode(text.Text); + if (wire == null) + return result; + + // Return a new result with only the text block replaced, rather than mutating the + // one the tool produced; the other fields are carried over unchanged. + return new CallToolResult + { + Content = new List { new TextContentBlock { Text = wire } }, + StructuredContent = result.StructuredContent, + IsError = result.IsError, + Meta = result.Meta, + }; + } +} diff --git a/src/PlanViewer.App/Mcp/GcfOutput.cs b/src/PlanViewer.App/Mcp/GcfOutput.cs new file mode 100644 index 00000000..a2dc84a2 --- /dev/null +++ b/src/PlanViewer.App/Mcp/GcfOutput.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using BlackwellSystems.Gcf; + +namespace PlanViewer.App.Mcp; + +// Optional GCF (Graph Compact Format, https://gcformat.com) output for the MCP tool +// results. When PLANVIEWER_OUTPUT_FORMAT=gcf, a call-tool filter (GcfCallToolFilter) +// re-encodes each tool's JSON result as a GCF generic wire: the repeated field names of +// the record arrays these tools return (Query Store plan lists, plan/connection/statement +// listings, ...) are factored into a single header, cutting the token cost of a +// record-heavy result by roughly a third of the server's compact JSON depending on shape +// (uniform numeric records win most; results dominated by free text such as query bodies +// win least). Opt-in, lossless, and never larger than the JSON. +public static class GcfOutput +{ + // True when GCF output is requested. Read from the environment on each call so it can + // be toggled per process (or per test) without a restart. + public static bool Enabled => + string.Equals( + Environment.GetEnvironmentVariable("PLANVIEWER_OUTPUT_FORMAT")?.Trim(), + "gcf", + StringComparison.OrdinalIgnoreCase + ); + + // Returns a GCF wire for the given JSON, or null to keep the JSON. Null is returned + // whenever the JSON does not parse, contains a number GCF cannot carry exactly (a + // non-integer beyond double precision, e.g. a high-precision decimal), GCF is not + // smaller than the JSON (never-grow guard), or the decoded wire does not equal the input + // (fail-safe), so enabling GCF never grows, drops, or garbles a tool result. + public static string? TryEncode(string json) + { + if (string.IsNullOrEmpty(json)) + return null; + + object? native; + try + { + using var doc = JsonDocument.Parse(json); + native = FromJson(doc.RootElement); + } + catch + { + return null; + } + + string wire; + try + { + wire = Gcf.EncodeGeneric(native); + } + catch + { + return null; + } + + // Never-grow guard: only offer GCF when it is actually smaller than the JSON the + // tool would otherwise return. + if (wire.Length >= json.Length) + return null; + + // Fail-safe: verify the wire against the INPUT, not against itself. Decode the wire + // back to a value and require it to equal the model the tool's JSON parsed to + // (`native`). FromJson has already declined any number the wire could not carry + // exactly, so a match here means the JSON survives the full JSON -> GCF -> value + // round-trip. Object key order may normalize to header order (semantically equal for + // JSON objects), so the key comparison is order-insensitive. + try + { + if (!ValuesEqual(Gcf.DecodeGeneric(wire), native)) + return null; + } + catch + { + return null; + } + + return wire; + } + + // Order-insensitive structural equality over the gcf-dotnet model (OrderedMap / List / + // long / double / string / bool / null), used to confirm a decoded wire equals the + // input model. + private static bool ValuesEqual(object? a, object? b) + { + if (a is null || b is null) + return a is null && b is null; + + if (a is OrderedMap ma && b is OrderedMap mb) + { + if (ma.Count != mb.Count) + return false; + foreach (var key in ma.Keys) + { + if (!mb.TryGetValue(key, out var vb) || !ValuesEqual(ma[key], vb)) + return false; + } + return true; + } + + if (a is List la && b is List lb) + { + if (la.Count != lb.Count) + return false; + for (var i = 0; i < la.Count; i++) + if (!ValuesEqual(la[i], lb[i])) + return false; + return true; + } + + if (a is string sa && b is string sb) + return sa == sb; + if (a is bool ba && b is bool bb) + return ba == bb; + if (IsNumber(a) && IsNumber(b)) + return NumbersEqual(a!, b!); + return false; + } + + private static bool IsNumber(object? v) => v is long || v is double; + + // long/long compare exactly; a long and an integer-valued double (an integer can decode + // as either) compare by value. Precision-lossy numbers never reach here: FromJson + // declined them before the wire was produced; the mixed branch still avoids widening the + // long to double so the guard cannot itself launder a value above 2^53. + private static bool NumbersEqual(object a, object b) + { + if (a is long al && b is long bl) + return al == bl; + if (a is double ad && b is double bd) + return ad.Equals(bd); + + // Mixed long/double. Compare without casting the long to double (that cast rounds + // above 2^53 and would let a lost value compare equal). The two are equal only when + // the double is integral, sits inside the long range, and equals the long exactly. + long lng; + double dbl; + if (a is long la) + { + lng = la; + dbl = (double)b; + } + else + { + lng = (long)b; + dbl = (double)a; + } + return dbl == Math.Floor(dbl) + && dbl >= long.MinValue + && dbl <= long.MaxValue + && (long)dbl == lng; + } + + // Converts a parsed JSON value into the gcf-dotnet native model (OrderedMap / List / + // scalars), preserving object key order. Integers are kept as long rather than double + // so large ids, counts, and durations are never float-rounded. + private static object? FromJson(JsonElement e) + { + switch (e.ValueKind) + { + case JsonValueKind.Object: + var map = new OrderedMap(); + foreach (var p in e.EnumerateObject()) + map.Add(p.Name, FromJson(p.Value)); + return map; + + case JsonValueKind.Array: + var list = new List(); + foreach (var item in e.EnumerateArray()) + list.Add(FromJson(item)); + return list; + + case JsonValueKind.String: + return e.GetString(); + + case JsonValueKind.Number: + if (e.TryGetInt64(out var l)) + return l; + // A non-integer is carried on the wire as an IEEE-754 double (SPEC 2.3.2). + // Keep it only when the double holds the JSON token exactly; otherwise + // decline the whole payload (this throw is caught in TryEncode and the tool + // result stays JSON) rather than emit a wire that has silently dropped + // precision. A token outside the decimal range is inherently double-domain. + var d = e.GetDouble(); + if (!NumberSurvivesAsDouble(e, d)) + throw new NotSupportedException("number not exactly representable as a double"); + return d; + + case JsonValueKind.True: + return true; + + case JsonValueKind.False: + return false; + + default: + return null; // Null / Undefined + } + } + + // Reports whether a double holds the JSON number token exactly, so a non-integer can + // be carried on the wire without silently dropping precision. A non-finite double (a + // token that overflowed to +/-Infinity, e.g. 1e400) never represents a finite token and + // is declined. Otherwise the double is exact when its shortest round-trip form + // reproduces the token, or, for a token in the decimal range, when the decimal it parsed + // to is unchanged. The shortest round-trip comparison is what makes a 16-significant-digit + // value such as 0.5029000043869019 (exactly representable, but 16 digits) survive: a + // (decimal)d cast keeps only 15 digits and would wrongly reject it. + private static bool NumberSurvivesAsDouble(JsonElement e, double d) + { + if (!double.IsFinite(d)) + return false; + if (d.ToString("R", CultureInfo.InvariantCulture) + .Equals(e.GetRawText(), StringComparison.Ordinal)) + return true; + return e.TryGetDecimal(out var exact) && (decimal)d == exact; + } +} diff --git a/src/PlanViewer.App/Mcp/McpHostService.cs b/src/PlanViewer.App/Mcp/McpHostService.cs index 41006a67..f457d138 100644 --- a/src/PlanViewer.App/Mcp/McpHostService.cs +++ b/src/PlanViewer.App/Mcp/McpHostService.cs @@ -79,7 +79,10 @@ so it never drifts from the actual release. */ }) .WithHttpTransport() .WithTools() - .WithTools(); + .WithTools() + /* Opt-in GCF output (PLANVIEWER_OUTPUT_FORMAT=gcf): one call-tool filter + re-encodes each tool's JSON result as a smaller, lossless GCF wire. */ + .WithRequestFilters(filters => filters.AddCallToolFilter(GcfCallToolFilter.Instance)); _app = builder.Build(); diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj index b49d227b..cef62612 100644 --- a/src/PlanViewer.App/PlanViewer.App.csproj +++ b/src/PlanViewer.App/PlanViewer.App.csproj @@ -19,6 +19,7 @@ + diff --git a/tests/PlanViewer.Core.Tests/GcfCallToolFilterTests.cs b/tests/PlanViewer.Core.Tests/GcfCallToolFilterTests.cs new file mode 100644 index 00000000..0c930ab9 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/GcfCallToolFilterTests.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using ModelContextProtocol.Protocol; +using PlanViewer.App.Mcp; +using Xunit; + +namespace PlanViewer.Core.Tests; + +[Collection("PlanViewerOutputFormatEnv")] +public class GcfCallToolFilterTests : IDisposable +{ + public GcfCallToolFilterTests() => + Environment.SetEnvironmentVariable("PLANVIEWER_OUTPUT_FORMAT", null); + + public void Dispose() => Environment.SetEnvironmentVariable("PLANVIEWER_OUTPUT_FORMAT", null); + + private static string RecordArrayJson() + { + var plans = new List(); + for (var i = 0; i < 20; i++) + plans.Add(new { query_id = 40000 + i, plan_id = 90000 + i * 3, executions = 100 + i }); + return JsonSerializer.Serialize( + new { server = "SQLPROD01", database = "AppDb", plan_count = 20, plans }, + new JsonSerializerOptions { WriteIndented = true } + ); + } + + private static CallToolResult ResultWith(params ContentBlock[] content) => + new() { Content = new List(content) }; + + private static string TextOf(CallToolResult r) => ((TextContentBlock)r.Content[0]).Text; + + [Fact] + public void Transform_Enabled_Rewrites_Single_Json_Block_As_Gcf() + { + Environment.SetEnvironmentVariable("PLANVIEWER_OUTPUT_FORMAT", "gcf"); + var json = RecordArrayJson(); + + var outResult = GcfCallToolFilter.Transform(ResultWith(new TextContentBlock { Text = json })); + + Assert.Single(outResult.Content); + Assert.StartsWith("GCF profile=generic", TextOf(outResult)); + } + + [Fact] + public void Transform_Disabled_Leaves_Result_Unchanged() + { + var json = RecordArrayJson(); + + var outResult = GcfCallToolFilter.Transform(ResultWith(new TextContentBlock { Text = json })); + + Assert.Equal(json, TextOf(outResult)); + } + + [Fact] + public void Transform_Error_Result_Is_Left_As_Json() + { + Environment.SetEnvironmentVariable("PLANVIEWER_OUTPUT_FORMAT", "gcf"); + var json = RecordArrayJson(); + + var result = ResultWith(new TextContentBlock { Text = json }); + result.IsError = true; + var outResult = GcfCallToolFilter.Transform(result); + + Assert.Equal(json, TextOf(outResult)); + } + + [Fact] + public void Transform_Multiple_Content_Blocks_Are_Left_Unchanged() + { + Environment.SetEnvironmentVariable("PLANVIEWER_OUTPUT_FORMAT", "gcf"); + var json = RecordArrayJson(); + + // A JSON text block alongside another block must not be rewritten (would drop the + // second block). + var result = ResultWith( + new TextContentBlock { Text = json }, + new TextContentBlock { Text = "second block" } + ); + var outResult = GcfCallToolFilter.Transform(result); + + Assert.Equal(2, outResult.Content.Count); + Assert.Equal(json, TextOf(outResult)); + } +} diff --git a/tests/PlanViewer.Core.Tests/GcfOutputTests.cs b/tests/PlanViewer.Core.Tests/GcfOutputTests.cs new file mode 100644 index 00000000..1706be63 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/GcfOutputTests.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using BlackwellSystems.Gcf; +using PlanViewer.App.Mcp; +using Xunit; + +namespace PlanViewer.Core.Tests; + +// Mutates the PLANVIEWER_OUTPUT_FORMAT environment variable; the collection keeps these +// tests from racing each other (and any other env-mutating test) in parallel. +[Collection("PlanViewerOutputFormatEnv")] +public class GcfOutputTests : IDisposable +{ + public GcfOutputTests() => Environment.SetEnvironmentVariable("PLANVIEWER_OUTPUT_FORMAT", null); + + public void Dispose() => Environment.SetEnvironmentVariable("PLANVIEWER_OUTPUT_FORMAT", null); + + // A representative get_query_store_top result: the {server, ..., plans:[...]} envelope + // wrapping an array of uniform per-plan records (the shape McpQueryStoreTools returns). + // Doubles are exact binary fractions so the payload round-trips deterministically; the + // precision-edge behavior is covered by the Numbered* tests below. + private static string QueryStorePlans(int rows) + { + var plans = Enumerable + .Range(0, rows) + .Select(i => new + { + session_id = $"{(1000000 + i):x}-4a2b-4c3d-9e00-abcdef012345", + query_id = (long)(40000 + i), + plan_id = (long)(90000 + i * 3), + query_hash = "0x8A3F00C1D2E4" + i.ToString("X4"), + query_plan_hash = "0x9B4E11D2E3F5" + i.ToString("X4"), + module_name = i % 4 == 0 ? $"dbo.usp_Proc{i % 9}" : null, + label = $"QS:AppDb Q{40000 + i} P{90000 + i * 3}", + query_text = "SELECT c.customer_id, c.name FROM dbo.Customers c WHERE c.active = 1 ORDER BY c.name", + executions = (long)((i * 137 + 5) % 100000), + total_cpu_ms = i * 100.5, + avg_cpu_ms = i * 0.5 + 1.0, + total_duration_ms = i * 250.25, + avg_duration_ms = i * 0.25 + 2.0, + total_logical_reads = (long)((i * 9931) % 5000000), + avg_logical_reads = (long)((i * 13) % 40000), + warning_count = i % 6, + missing_index_count = i % 3, + last_executed_utc = "2026-09-04 12:00:00", + loaded = true, + load_error = (string?)null, + }) + .ToList(); + + return JsonSerializer.Serialize( + new { server = "SQLPROD01", database = "AppDb", order_by = "cpu", hours_back = 24, plan_count = rows, plans }, + new JsonSerializerOptions { WriteIndented = false } // matches the server's compact MCP output + ); + } + + [Fact] + public void Enabled_Reflects_Environment() + { + Assert.False(GcfOutput.Enabled); + + foreach (var value in new[] { "gcf", "GCF", " gcf " }) + { + Environment.SetEnvironmentVariable("PLANVIEWER_OUTPUT_FORMAT", value); + Assert.True(GcfOutput.Enabled); + } + + Environment.SetEnvironmentVariable("PLANVIEWER_OUTPUT_FORMAT", "json"); + Assert.False(GcfOutput.Enabled); + } + + [Fact] + public void TryEncode_RecordArray_Is_Smaller_And_RoundTrips() + { + var json = QueryStorePlans(30); + + var wire = GcfOutput.TryEncode(json); + + Assert.NotNull(wire); + Assert.StartsWith("GCF profile=generic", wire); + Assert.True(wire!.Length < json.Length, "GCF wire must be smaller than the JSON"); + // Decoding then re-encoding reproduces the wire (stable, lossless round-trip). + Assert.Equal(wire, Gcf.EncodeGeneric(Gcf.DecodeGeneric(wire))); + } + + [Fact] + public void TryEncode_Decoded_Wire_Carries_Input_Values() + { + // The claim is that the substituted wire carries the SAME value as the JSON, not + // merely that it re-encodes to itself. Decode the wire and assert it reproduces the + // input's values, checked against the literals the payload was built from. + var wire = GcfOutput.TryEncode(QueryStorePlans(30)); + Assert.NotNull(wire); + + var root = Assert.IsType(Gcf.DecodeGeneric(wire!)); + Assert.Equal("SQLPROD01", (string?)root["server"]); + Assert.Equal("AppDb", (string?)root["database"]); + Assert.Equal(24L, (long)root["hours_back"]!); + Assert.Equal(30L, (long)root["plan_count"]!); + + var plans = Assert.IsType>(root["plans"]); + Assert.Equal(30, plans.Count); + + var first = Assert.IsType(plans[0]); + Assert.Equal(40000L, (long)first["query_id"]!); + Assert.Equal(90000L, (long)first["plan_id"]!); + Assert.Equal(5L, (long)first["executions"]!); + Assert.True((bool)first["loaded"]!); + + var last = Assert.IsType(plans[29]); + Assert.Equal(40029L, (long)last["query_id"]!); // 40000 + 29 + Assert.Equal(90087L, (long)last["plan_id"]!); // 90000 + 29 * 3 + } + + [Fact] + public void TryEncode_Tiny_Payload_Falls_Back_To_Json() + { + var json = JsonSerializer.Serialize(new { status = "ok" }); + Assert.Null(GcfOutput.TryEncode(json)); // GCF not smaller: keep JSON + } + + [Fact] + public void TryEncode_Invalid_Json_Falls_Back() + { + Assert.Null(GcfOutput.TryEncode("{not json")); + } + + private static string Numbered(object value, int rows) + { + var arr = Enumerable + .Range(0, rows) + .Select(_ => new Dictionary { ["metric"] = value, ["server"] = "SQLPROD01" }) + .ToList(); + return JsonSerializer.Serialize( + new { rows = arr }, + new JsonSerializerOptions { WriteIndented = false } // matches the server's compact MCP output + ); + } + + // Builds the same {rows:[{metric,server}]} shape but writes the metric as a raw JSON + // number token, so tokens that cannot be produced from a CLR value (an overflowing + // literal such as 1e400) can be fed through the encoder exactly as a collector would. + private static string NumberedRaw(string numberToken, int rows) + { + var items = string.Join( + ",", + Enumerable + .Range(0, rows) + .Select(_ => $"{{\"metric\":{numberToken},\"server\":\"SQLPROD01\"}}") + ); + return $"{{\"rows\":[{items}]}}"; + } + + [Fact] + public void TryEncode_Keeps_Decimal_That_Fits_Double() + { + // 33.5 is exactly representable as a double, so it round-trips and GCF is kept. + var wire = GcfOutput.TryEncode(Numbered(33.5, 20)); + + Assert.NotNull(wire); + Assert.Contains("33.5", wire); + } + + [Fact] + public void TryEncode_Keeps_16Digit_ShortestRoundTrip_Double() + { + // 0.5029000043869019 is a real captured float8 value: a 16-significant-digit + // shortest-round-trip double that IS exactly representable. It must encode. A + // (decimal)d guard keeps only 15 digits and would wrongly decline it. + var wire = GcfOutput.TryEncode(Numbered(0.5029000043869019, 20)); + + Assert.NotNull(wire); + Assert.Contains("0.5029000043869019", wire); + } + + [Fact] + public void TryEncode_Declines_NonFinite_Double() + { + // A token that overflows double (1e400) parses to Infinity. The guard must decline + // it rather than silently encode Infinity where the JSON carried a finite token. + Assert.Null(GcfOutput.TryEncode(NumberedRaw("1e400", 20))); + } + + [Fact] + public void TryEncode_Declines_High_Precision_Decimal() + { + // 33.333333333333333 (17 significant digits) cannot be held by a double without + // loss. A same-shape array of integers this size encodes to GCF, so a null here is + // the precision guard declining rather than never-grow: the result stays JSON + // instead of a silently rounded wire. + Assert.Null(GcfOutput.TryEncode(Numbered(33.333333333333333m, 20))); + } + + [Fact] + public void TryEncode_Declines_UInt64_Above_Int64() + { + // ulong.MaxValue exceeds Int64 and is not exactly a double either; keep JSON. + var json = Numbered(18446744073709551615UL, 20); + Assert.Null(GcfOutput.TryEncode(json)); + } + + [Fact] + public void TryEncode_Preserves_Int64_Above_2Pow53() + { + // A default JSON-to-double parse would round 9007199254740993 to ...992; the + // encoder must keep the exact integer, not render it as a float. + var rows = Enumerable + .Range(0, 20) + .Select(_ => new { id = 9007199254740993L, name = "x" }) + .ToList(); + var json = JsonSerializer.Serialize( + new { rows }, + new JsonSerializerOptions { WriteIndented = false } // matches the server's compact MCP output + ); + + var wire = GcfOutput.TryEncode(json); + + Assert.NotNull(wire); + Assert.Contains("9007199254740993", wire); + Assert.DoesNotContain("9.007", wire); // not a rounded float + } +} diff --git a/tests/PlanViewer.Core.Tests/GcfPipelineTests.cs b/tests/PlanViewer.Core.Tests/GcfPipelineTests.cs new file mode 100644 index 00000000..169a926b --- /dev/null +++ b/tests/PlanViewer.Core.Tests/GcfPipelineTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipelines; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using PlanViewer.App.Mcp; +using Xunit; + +namespace PlanViewer.Core.Tests; + +// End-to-end proof that the GCF filter actually fires in the MCP request pipeline when +// registered the way McpHostService registers it — AddMcpServer(...).WithTools(...) +// .WithRequestFilters(f => f.AddCallToolFilter(GcfCallToolFilter.Instance)) — rather than +// only that Transform()/TryEncode() work in isolation. A real MCP client calls a tool over +// an in-process stream transport and the response text is asserted. +[Collection("PlanViewerOutputFormatEnv")] +public class GcfPipelineTests : IDisposable +{ + public GcfPipelineTests() => Environment.SetEnvironmentVariable("PLANVIEWER_OUTPUT_FORMAT", null); + + public void Dispose() => Environment.SetEnvironmentVariable("PLANVIEWER_OUTPUT_FORMAT", null); + + [McpServerToolType] + public class PipelineTools + { + // Returns the record-array envelope shape the real Query Store / listing tools + // return, so the filter has something it can shrink. + [McpServerTool(Name = "records")] + public static string Records() => + JsonSerializer.Serialize(new + { + server = "SQLPROD01", + total = 20, + rows = Enumerable + .Range(0, 20) + .Select(i => new { id = 1000 + i, name = $"row{i}", value = i * 10 }) + .ToList(), + }); + } + + private static async Task CallRecordsAsync() + { + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + + var services = new ServiceCollection(); + services + .AddMcpServer() + .WithStreamServerTransport(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream()) + .WithTools() + .WithRequestFilters(filters => filters.AddCallToolFilter(GcfCallToolFilter.Instance)); + + await using var provider = services.BuildServiceProvider(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var hosted = provider.GetServices().ToList(); + foreach (var service in hosted) + await service.StartAsync(cts.Token); + + try + { + var transport = new StreamClientTransport( + serverInput: clientToServer.Writer.AsStream(), + serverOutput: serverToClient.Reader.AsStream()); + await using var client = await McpClient.CreateAsync(transport, cancellationToken: cts.Token); + + var result = await client.CallToolAsync( + "records", + new Dictionary(), + cancellationToken: cts.Token); + + return ((TextContentBlock)result.Content[0]).Text; + } + finally + { + foreach (var service in hosted) + await service.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task Filter_Rewrites_Tool_Result_As_Gcf_When_Enabled() + { + Environment.SetEnvironmentVariable("PLANVIEWER_OUTPUT_FORMAT", "gcf"); + + var text = await CallRecordsAsync(); + + Assert.StartsWith("GCF profile=generic", text); + } + + [Fact] + public async Task Tool_Result_Stays_Json_When_Disabled() + { + var text = await CallRecordsAsync(); + + Assert.DoesNotContain("GCF profile=generic", text); + Assert.Contains("\"server\"", text); // still the JSON the tool returned + } +}