diff --git a/Darling/Darling.Tests/GcfCallToolFilterTests.cs b/Darling/Darling.Tests/GcfCallToolFilterTests.cs new file mode 100644 index 00000000..06701097 --- /dev/null +++ b/Darling/Darling.Tests/GcfCallToolFilterTests.cs @@ -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(); + 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(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)); + } +} diff --git a/Darling/Darling.Tests/GcfOutputTests.cs b/Darling/Darling.Tests/GcfOutputTests.cs new file mode 100644 index 00000000..2e74e19a --- /dev/null +++ b/Darling/Darling.Tests/GcfOutputTests.cs @@ -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 { ["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 + } +} diff --git a/Darling/Darling.Tests/packages.lock.json b/Darling/Darling.Tests/packages.lock.json index d870e712..bc41f6a4 100644 --- a/Darling/Darling.Tests/packages.lock.json +++ b/Darling/Darling.Tests/packages.lock.json @@ -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, )", @@ -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, )", diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs index 46cb7da1..54eebf0d 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs @@ -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(); + .WithGeminiCompatibleTools() + /* 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(); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/GcfCallToolFilter.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/GcfCallToolFilter.cs new file mode 100644 index 00000000..f48bbd40 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/GcfCallToolFilter.cs @@ -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 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/Darling/PerformanceMonitor.Darling.Service/Mcp/GcfOutput.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/GcfOutput.cs new file mode 100644 index 00000000..ae2c6581 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/GcfOutput.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using BlackwellSystems.Gcf; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +// Optional GCF (Graph Compact Format, https://gcformat.com) output for the MCP tool +// results. When DARLING_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 (blocking events, alerts, wait stats, config, ...) +// are factored into a single header and the indentation is dropped, cutting the token +// cost of a result roughly in half versus the pretty-printed JSON. 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("DARLING_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. + private static bool NumbersEqual(object a, object b) + { + if (a is long al && b is long bl) + return al == bl; + var da = a is long la ? la : (double)a; + var db = b is long lb ? lb : (double)b; + return da.Equals(db); + } + + // 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 (e.TryGetDecimal(out var exact) && (decimal)d != exact) + 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 + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj b/Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj index 0b96a916..65d0bc78 100644 --- a/Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj +++ b/Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj @@ -33,6 +33,7 @@ + diff --git a/Darling/README.md b/Darling/README.md index 3ff53856..64db454d 100644 --- a/Darling/README.md +++ b/Darling/README.md @@ -479,6 +479,8 @@ claude mcp add --transport http --scope user sql-monitor-darling http://localhos If the port is already in use at startup, the MCP server logs an error and does not start; collection is unaffected. +**Output format (GCF).** By default the tools return JSON. Setting the environment variable `DARLING_OUTPUT_FORMAT=gcf` makes the MCP server return [Graph Compact Format](https://gcformat.com) instead: the repeated field names of the record arrays these tools return (blocking pairs, wait stats, alerts, config rows, …) are factored into a single header and the indentation is dropped. The saving is payload-shaped: roughly half on a record-heavy result, and less on text-dominated ones (multi-line query text, plan or deadlock XML) where the field names are a smaller share of the bytes. In the wire a null field is written as `-` and an absent field (a key some rows omit) as `~`. It is opt-in and conservative: applied per result, and only when the GCF wire is both smaller than the JSON and decodes back to it exactly; a result carrying a number GCF cannot represent exactly (a non-integer beyond double precision) stays JSON, as does any result where the wire would be larger, so no result is ever grown, dropped, or garbled. `BlackwellSystems.Gcf` is a zero-dependency package. + ### web The embedded read-only **web dashboard** — a browser view of the monitoring store, served over HTTP on its OWN port (default **5153**), separate from the MCP server. It is a distinct surface from [`### mcp`](#mcp): its own enable flag, port, token, and exposure block, because the two gate different blast radii (the MCP token guards `analyze_server`'s **live outbound** connections to your monitored SQL Servers; the web dashboard is **read-only over the collected store**). It connects to the store as the least-privilege `viewer` role. Loopback-only by default; see [Opt-in Network Endpoints (LAN)](#opt-in-network-endpoints-lan) to reach it from the LAN. diff --git a/Directory.Packages.props b/Directory.Packages.props index cd477d4d..20c87dd9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ true +