Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
44 changes: 44 additions & 0 deletions src/PlanViewer.App/Mcp/GcfCallToolFilter.cs
Original file line number Diff line number Diff line change
@@ -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<CallToolRequestParams, CallToolResult> 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<ContentBlock> { new TextContentBlock { Text = wire } },
StructuredContent = result.StructuredContent,
IsError = result.IsError,
Meta = result.Meta,
};
}
}
219 changes: 219 additions & 0 deletions src/PlanViewer.App/Mcp/GcfOutput.cs
Original file line number Diff line number Diff line change
@@ -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<object?> la && b is List<object?> 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<object?>();
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;
}
}
5 changes: 4 additions & 1 deletion src/PlanViewer.App/Mcp/McpHostService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ so it never drifts from the actual release. */
})
.WithHttpTransport()
.WithTools<McpPlanTools>()
.WithTools<McpQueryStoreTools>();
.WithTools<McpQueryStoreTools>()
/* 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();

Expand Down
1 change: 1 addition & 0 deletions src/PlanViewer.App/PlanViewer.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.20" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.20" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.4.1" />
<PackageReference Include="BlackwellSystems.Gcf" Version="0.2.1" />
<PackageReference Include="AvaloniaEdit.TextMate.Grammars" Version="0.10.12.1" />
<PackageReference Include="Meziantou.Framework.Win32.CredentialManager" Version="3.0.1" />
<PackageReference Include="ModelContextProtocol" Version="2.2.0" />
Expand Down
86 changes: 86 additions & 0 deletions tests/PlanViewer.Core.Tests/GcfCallToolFilterTests.cs
Original file line number Diff line number Diff line change
@@ -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<object>();
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<ContentBlock>(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));
}
}
Loading
Loading