Skip to content
Open
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
86 changes: 86 additions & 0 deletions Darling/Darling.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 PerformanceMonitor.Darling.Service.Mcp;
using Xunit;

namespace Darling.Tests;

[Collection("DarlingOutputFormatEnv")]
public class GcfCallToolFilterTests : IDisposable
{
public GcfCallToolFilterTests() =>
Environment.SetEnvironmentVariable("DARLING_OUTPUT_FORMAT", null);

public void Dispose() => Environment.SetEnvironmentVariable("DARLING_OUTPUT_FORMAT", null);

private static string RecordArrayJson()
{
var events = new List<object>();
for (var i = 0; i < 20; i++)
events.Add(new { session_id = 60 + i, wait_type = "LCK_M_X", wait_ms = 1000 + i });
return JsonSerializer.Serialize(
new { server = "S1", total = 20, events },
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("DARLING_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("DARLING_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("DARLING_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));
}
}
151 changes: 151 additions & 0 deletions Darling/Darling.Tests/GcfOutputTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using BlackwellSystems.Gcf;
using ModelContextProtocol.Protocol;
using PerformanceMonitor.Darling.Service.Mcp;
using Xunit;

namespace Darling.Tests;

// Mutates the DARLING_OUTPUT_FORMAT environment variable; the collection keeps these
// tests from racing each other (and any other env-mutating test) in parallel.
[Collection("DarlingOutputFormatEnv")]
public class GcfOutputTests : IDisposable
{
public GcfOutputTests() => Environment.SetEnvironmentVariable("DARLING_OUTPUT_FORMAT", null);

public void Dispose() => Environment.SetEnvironmentVariable("DARLING_OUTPUT_FORMAT", null);

private static string Blocking(int rows)
{
var events = Enumerable
.Range(0, rows)
.Select(i => new
{
blocked_session_id = 60 + i,
blocking_session_id = 55 + (i % 5),
blocked_wait_type = "LCK_M_X",
wait_duration_ms = 1000 + i * 137,
blocked_database = "OrdersDB",
blocked_query = "UPDATE dbo.Orders SET Status = @1 WHERE Id = @2",
has_report_xml = true,
})
.ToList();

return JsonSerializer.Serialize(
new
{
server = "SQLPROD01",
hours_back = 24,
total_events = rows,
events,
},
new JsonSerializerOptions { WriteIndented = true }
);
}

[Fact]
public void Enabled_Reflects_Environment()
{
Assert.False(GcfOutput.Enabled);

foreach (var value in new[] { "gcf", "GCF", " gcf " })
{
Environment.SetEnvironmentVariable("DARLING_OUTPUT_FORMAT", value);
Assert.True(GcfOutput.Enabled);
}

Environment.SetEnvironmentVariable("DARLING_OUTPUT_FORMAT", "json");
Assert.False(GcfOutput.Enabled);
}

[Fact]
public void TryEncode_RecordArray_Is_Smaller_And_RoundTrips()
{
var json = Blocking(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_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<string, object> { ["metric"] = value, ["server"] = "SQLPROD01" })
.ToList();
return JsonSerializer.Serialize(
new { rows = arr },
new JsonSerializerOptions { WriteIndented = true }
);
}

[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_Declines_High_Precision_Decimal()
{
// 33.333333333333333 (17 significant digits) cannot be held by a double without
// loss. A same-shape array of integers at this size encodes to GCF (asserted by the
// Blocking round-trip test), so a null here is the precision guard declining rather
// than the never-grow guard: 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 = true }
);

var wire = GcfOutput.TryEncode(json);

Assert.NotNull(wire);
Assert.Contains("9007199254740993", wire);
Assert.DoesNotContain("9.007", wire); // not a rounded float
}
}
7 changes: 7 additions & 0 deletions Darling/Darling.Tests/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,7 @@
"performancemonitor.darling.service": {
"type": "Project",
"dependencies": {
"BlackwellSystems.Gcf": "[0.2.0, )",
"Microsoft.Data.SqlClient": "[7.0.2, )",
"Microsoft.Extensions.Hosting": "[10.0.10, )",
"Microsoft.Extensions.Hosting.WindowsServices": "[10.0.10, )",
Expand Down Expand Up @@ -777,6 +778,12 @@
"ScottPlot.WPF": "[5.1.59, )"
}
},
"BlackwellSystems.Gcf": {
"type": "CentralTransitive",
"requested": "[0.2.0, )",
"resolved": "0.2.0",
"contentHash": "HnL0i5xnmNDxv0j4kqGTgUxZ+uTEuRtM4S31NYS/XdL1kaQ77TfknqpW0yUALyt4KJRmzVKXgI2CmqdhvMQ9cA=="
},
"CredentialManagement": {
"type": "CentralTransitive",
"requested": "[1.0.2, )",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,12 @@ or tear down FLEET monitoring conversationally. The service-side twin of the Vie
resolver the read tools use. The mcp role carries the narrow INSERT/UPDATE/DELETE grant on ONLY
config.config_monitored_servers (the encrypted_password column stays SELECT-carved) — never the
config pivot or a schema-wide write. */
.WithGeminiCompatibleTools<DarlingMcpServerAdminTools>();
.WithGeminiCompatibleTools<DarlingMcpServerAdminTools>()
/* Optional GCF (Graph Compact Format) output: a single call-tool filter that,
when DARLING_OUTPUT_FORMAT=gcf, re-encodes each tool's JSON result as a GCF
generic wire. Registered once; covers every tool. Opt-in, lossless, and
never larger than the JSON (see GcfCallToolFilter / GcfOutput). */
.WithRequestFilters(filters => filters.AddCallToolFilter(GcfCallToolFilter.Instance));

_app = builder.Build();

Expand Down
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 PerformanceMonitor.Darling.Service.Mcp;

// A single call-tool filter that offers each tool's result as GCF instead of JSON when
// DARLING_OUTPUT_FORMAT=gcf. Registered once in DarlingMcpHostService, 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,
};
}
}
Loading
Loading