From f88b4463a15123ffa5f426d3d6f610f09cf28aca Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 5 Jun 2026 14:59:54 +1000 Subject: [PATCH 1/9] Start work on metrics command group --- .../Cli/Commands/Metrics/DimensionCommand.cs | 6 + .../Cli/Commands/Metrics/DimensionsCommand.cs | 6 + .../Cli/Commands/Metrics/SearchCommand.cs | 124 ++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs create mode 100644 src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs create mode 100644 src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs new file mode 100644 index 00000000..71de1d25 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs @@ -0,0 +1,6 @@ +namespace SeqCli.Cli.Commands.Metrics; + +class DimensionCommand +{ + +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs new file mode 100644 index 00000000..9190e8ae --- /dev/null +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs @@ -0,0 +1,6 @@ +namespace SeqCli.Cli.Commands.Metrics; + +class DimensionsCommand +{ + +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs b/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs new file mode 100644 index 00000000..3f91ad7b --- /dev/null +++ b/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs @@ -0,0 +1,124 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Seq.Api.Model.Data; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using SeqCli.Util; +using Serilog; + +namespace SeqCli.Cli.Commands.Metrics; + +[Command("metrics", "search", "List available metric definitions", + Example = "seqcli metrics search -f \"@Resource.service.name = 'proxy'\" -c 100")] +class SearchCommand : Command +{ + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + readonly DateRangeFeature _range; + readonly StoragePathFeature _storagePath; + string? _filter; + readonly List _groups = []; + int _count = 1; + bool _trace; + + public SearchCommand() + { + Options.Add( + "f=|filter=", + "A filter to apply to the search, including metric name/description text in double quotes, for example `\"cpu\" and Host = 'xmpweb-01.example.com'`", + v => _filter = v); + + Options.Add( + "g=|group=", + "Group key for metric definition breakdown; this argument can be used multiple times", + c => _groups.Add(ArgumentString.Normalize(c) ?? throw new ArgumentException("Group keys require a value."))); + + Options.Add( + "c=|count=", + $"The maximum number of metric definitions to retrieve; the default is {_count}", + v => _count = int.Parse(v, CultureInfo.InvariantCulture)); + + _range = Enable(); + // Native is not supported because accessor expressions appear in the output, and the escaping applied to them + // as native strings does more harm than good. + _output = Enable(); + _storagePath = Enable(); + + Options.Add("trace", "Enable detailed (server-side) query tracing", _ => _trace = true); + + _connection = Enable(); + } + + protected override async Task Run() + { + try + { + var config = RuntimeConfigurationLoader.Load(_storagePath); + var output = _output.GetOutputFormat(config); + var connection = SeqConnectionFactory.Connect(_connection, config); + + string? filter = null; + if (!string.IsNullOrWhiteSpace(_filter)) + filter = (await connection.Expressions.ToStrictAsync(_filter)).StrictExpression; + + var result = await connection.Metrics.SearchAsync( + _groups, + filter, + _count, + rangeStartUtc: _range.Start, + rangeEndUtc: _range.End, + trace: _trace); + + // We convert the metric into a query result to improve formatting consistency. Room for an abstraction of + // some kind here. + var rows = new List(); + foreach (var metric in result.Metrics) + { + var row = new List + { + metric.Accessor, + metric.Kind, + metric.Unit, + metric.Description + }; + + foreach (var value in metric.GroupKey) + row.Add(value); + + rows.Add(row.ToArray()); + } + var asRowset = new QueryResultPart + { + Columns = new[] { "Accessor", "Kind", "Unit", "Description" }.Concat(_groups).ToArray(), + Rows = rows.ToArray() + }; + + output.WriteQueryResult(asRowset); + + return 0; + } + catch (Exception ex) + { + Log.Error(ex, "Could not retrieve metrics: {ErrorMessage}", ex.Message); + return 1; + } + } +} \ No newline at end of file From d3e2e600b62b19fd5c7df3a1990a0bc87ad94e32 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 5 Jun 2026 15:15:21 +1000 Subject: [PATCH 2/9] Dimensions command --- .../Cli/Commands/Metrics/DimensionsCommand.cs | 91 ++++++++++++++++++- .../Cli/Commands/Metrics/SearchCommand.cs | 8 +- src/SeqCli/Output/OutputFormat.cs | 19 ++-- 3 files changed, 104 insertions(+), 14 deletions(-) diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs index 9190e8ae..99e2347e 100644 --- a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs @@ -1,6 +1,93 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Globalization; +using System.Threading.Tasks; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using Serilog; + namespace SeqCli.Cli.Commands.Metrics; -class DimensionsCommand +[Command("metrics", "dimensions", "List the dimensions that apply to a given metric", + Example = "seqcli metrics dimensions -m http.response.status_code")] +class DimensionsCommand : Command { - + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + readonly DateRangeFeature _range; + readonly StoragePathFeature _storagePath; + string? _metric; + int _count = 30; + bool _trace; + + public DimensionsCommand() + { + Options.Add( + "m=|metric=", + "The metric name, for example `hats-sold` or `http.request.duration`", + v => _metric= v); + + Options.Add( + "c=|count=", + $"The maximum number of dimensions to retrieve; the default is {_count}", + v => _count = int.Parse(v, CultureInfo.InvariantCulture)); + + _range = Enable(); + _output = Enable(); + _storagePath = Enable(); + + Options.Add("trace", "Enable detailed (server-side) query tracing", _ => _trace = true); + + _connection = Enable(); + } + + protected override async Task Run() + { + try + { + var config = RuntimeConfigurationLoader.Load(_storagePath); + var output = _output.GetOutputFormat(config); + var connection = SeqConnectionFactory.Connect(_connection, config); + + var result = await connection.Metrics.ListDimensionsAsync( + _count, + _metric, + rangeStartUtc: _range.Start, + rangeEndUtc: _range.End, + trace: _trace); + + if (output.Json) + { + output.WriteObject(result); + } + else + { + foreach (var dimension in result) + { + output.WriteText(dimension.Accessor); + } + } + + return 0; + } + catch (Exception ex) + { + Log.Error(ex, "Could not retrieve metrics: {ErrorMessage}", ex.Message); + return 1; + } + } } \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs b/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs index 3f91ad7b..5cc1c3ee 100644 --- a/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs @@ -27,7 +27,7 @@ namespace SeqCli.Cli.Commands.Metrics; [Command("metrics", "search", "List available metric definitions", - Example = "seqcli metrics search -f \"@Resource.service.name = 'proxy'\" -c 100")] + Example = "seqcli metrics search -f \"@Resource.service.name = 'proxy'\" -c 512")] class SearchCommand : Command { readonly ConnectionFeature _connection; @@ -36,7 +36,7 @@ class SearchCommand : Command readonly StoragePathFeature _storagePath; string? _filter; readonly List _groups = []; - int _count = 1; + int _count = 30; bool _trace; public SearchCommand() @@ -94,7 +94,7 @@ protected override async Task Run() { var row = new List { - metric.Accessor, + metric.Name ?? metric.Accessor, metric.Kind, metric.Unit, metric.Description @@ -107,7 +107,7 @@ protected override async Task Run() } var asRowset = new QueryResultPart { - Columns = new[] { "Accessor", "Kind", "Unit", "Description" }.Concat(_groups).ToArray(), + Columns = new[] { "Name", "Kind", "Unit", "Description" }.Concat(_groups).ToArray(), Rows = rows.ToArray() }; diff --git a/src/SeqCli/Output/OutputFormat.cs b/src/SeqCli/Output/OutputFormat.cs index 0e7c47d7..fe35f693 100644 --- a/src/SeqCli/Output/OutputFormat.cs +++ b/src/SeqCli/Output/OutputFormat.cs @@ -13,6 +13,7 @@ // limitations under the License. using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; @@ -146,14 +147,16 @@ public void WriteObject(object value) if (Json) { - var jo = JObject.FromObject( - value, - JsonSerializer.CreateDefault(new JsonSerializerSettings { - DateParseHandling = DateParseHandling.None, - Converters = { - new StringEnumConverter() - } - })); + var settings = JsonSerializer.CreateDefault(new JsonSerializerSettings + { + DateParseHandling = DateParseHandling.None, + Converters = + { + new StringEnumConverter() + } + }); + + var jo = value is ICollection and not IDictionary ? (JToken)JArray.FromObject(value, settings) : JObject.FromObject(value, settings); // Using the same method of JSON colorization as above From 2fe4cffd19cf14c92ae4f1b296c06865bdc85266 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 5 Jun 2026 16:47:13 +1000 Subject: [PATCH 3/9] Dimension (values) command --- .../Cli/Commands/Metrics/DimensionCommand.cs | 100 +++++++++++++++++- .../Cli/Commands/Metrics/DimensionsCommand.cs | 8 +- src/SeqCli/Csv/CsvWriter.cs | 21 +--- src/SeqCli/Output/OutputFormat.cs | 25 ++++- 4 files changed, 131 insertions(+), 23 deletions(-) diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs index 71de1d25..f3296992 100644 --- a/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs @@ -1,6 +1,102 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Globalization; +using System.Threading.Tasks; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using Serilog; + namespace SeqCli.Cli.Commands.Metrics; -class DimensionCommand +[Command("metrics", "dimension", "List distinct values for a metric dimension", + Example = "seqcli metrics dimension --accessor @Resource.service.name")] +class DimensionCommand : Command { - + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + readonly DateRangeFeature _range; + readonly StoragePathFeature _storagePath; + string? _accessor; + int _count = 30; + bool _trace; + + public DimensionCommand() + { + Options.Add( + "d=|accessor=", + "The dimension accessor, e.g. `cpu.mode`", + v => _accessor= v); + + Options.Add( + "c=|count=", + $"The maximum number of dimensions to retrieve; the default is {_count}", + v => _count = int.Parse(v, CultureInfo.InvariantCulture)); + + _range = Enable(); + _output = Enable(new OutputFormatFeature(supportNative: true)); + _storagePath = Enable(); + + Options.Add("trace", "Enable detailed (server-side) query tracing", _ => _trace = true); + + _connection = Enable(); + } + + protected override async Task Run() + { + try + { + if (string.IsNullOrWhiteSpace(_accessor)) + { + Log.Error("A dimension `--accessor` must be specified"); + return 1; + } + + var config = RuntimeConfigurationLoader.Load(_storagePath); + var output = _output.GetOutputFormat(config); + var connection = SeqConnectionFactory.Connect(_connection, config); + + var result = await connection.Metrics.ListDimensionValuesAsync( + _accessor, + _count, + rangeStartUtc: _range.Start, + rangeEndUtc: _range.End, + trace: _trace); + + if (output.Json) + { + // In the JSON case we write an array with all values. + output.WriteObject(result); + } + else + { + // Native and plain text formatting use one-per-line output (both allow multi-line strings, but + // string boundaries are clearer in native mode). + foreach (var value in result) + { + output.WriteObject(value); + } + } + + return 0; + } + catch (Exception ex) + { + Log.Error(ex, "Could not retrieve metrics: {ErrorMessage}", ex.Message); + return 1; + } + } } \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs index 99e2347e..51cb9297 100644 --- a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs @@ -22,7 +22,7 @@ namespace SeqCli.Cli.Commands.Metrics; -[Command("metrics", "dimensions", "List the dimensions that apply to a given metric", +[Command("metrics", "dimensions", "List the dimensions associated with a given metric", Example = "seqcli metrics dimensions -m http.response.status_code")] class DimensionsCommand : Command { @@ -59,6 +59,12 @@ protected override async Task Run() { try { + if (string.IsNullOrWhiteSpace(_metric)) + { + Log.Error("A `--metric` must be specified"); + return 1; + } + var config = RuntimeConfigurationLoader.Load(_storagePath); var output = _output.GetOutputFormat(config); var connection = SeqConnectionFactory.Connect(_connection, config); diff --git a/src/SeqCli/Csv/CsvWriter.cs b/src/SeqCli/Csv/CsvWriter.cs index e984ea05..fff15271 100644 --- a/src/SeqCli/Csv/CsvWriter.cs +++ b/src/SeqCli/Csv/CsvWriter.cs @@ -1,5 +1,4 @@ using System; -using System.Globalization; using System.IO; using Seq.Api.Model.Data; using SeqCli.Mcp.Data; @@ -9,7 +8,7 @@ namespace SeqCli.Csv; static class CsvWriter { - public static void WriteQueryResult(QueryResultPart result, ConsoleTheme theme, TextWriter output) + public static void WriteQueryResult(QueryResultPart result, Func stringify, ConsoleTheme theme, TextWriter output) { if (!string.IsNullOrWhiteSpace(result.Error)) { @@ -24,14 +23,14 @@ public static void WriteQueryResult(QueryResultPart result, ConsoleTheme theme, var firstCol = true; foreach (var value in row) { - WriteCell(output, theme, value, ref firstCol, isHeadingRow: first); + WriteCell(output, theme, value, stringify, ref firstCol, isHeadingRow: first); } first = false; output.WriteLine(); }); } - static void WriteCell(TextWriter output, ConsoleTheme theme, object? value, ref bool firstCol, bool isHeadingRow = false) + static void WriteCell(TextWriter output, ConsoleTheme theme, object? value, Func stringify, ref bool firstCol, bool isHeadingRow = false) { if (firstCol) { @@ -48,19 +47,7 @@ static void WriteCell(TextWriter output, ConsoleTheme theme, object? value, ref output.Write('"'); theme.Reset(output); - var valueAsString = value switch - { - null => "null", - true => "true", - false => "false", - decimal - or double or float or Half - or byte or ushort or uint or ulong or UInt128 or - sbyte or short or int or long or Int128 => ((IFormattable)value).ToString(null, CultureInfo.InvariantCulture), - DateTime dt => dt.ToString("o"), - DateTimeOffset dto => dto.ToString("o"), - _ => value.ToString() ?? "" - }; + var valueAsString = stringify(value); var dataStyle = isHeadingRow ? ConsoleThemeStyle.Name : ConsoleThemeStyle.Text; var doubleQuote = valueAsString.IndexOf('"'); diff --git a/src/SeqCli/Output/OutputFormat.cs b/src/SeqCli/Output/OutputFormat.cs index fe35f693..f35fcb6b 100644 --- a/src/SeqCli/Output/OutputFormat.cs +++ b/src/SeqCli/Output/OutputFormat.cs @@ -172,11 +172,12 @@ public void WriteObject(object value) } else if (Text) { - Console.WriteLine(value.ToString()); + Console.WriteLine(Stringify(value)); } else { - throw new InvalidOperationException("Native formatting not supported for raw objects."); + NativeFormatter.WriteValue(Console.Out, value); + Console.WriteLine(); } } @@ -208,7 +209,7 @@ public void WriteQueryResult(QueryResultPart result) } else { - CsvWriter.WriteQueryResult(result, Theme, Console.Out); + CsvWriter.WriteQueryResult(result, Stringify, Theme, Console.Out); } } @@ -297,4 +298,22 @@ static LogEventPropertyValue CreatePropertyValue(object value) return new ScalarValue(value); } } + + static string Stringify(object? value) + { + return value switch + { + null => "null", + true => "true", + false => "false", + decimal + or double or float or Half + or byte or ushort or uint or ulong or UInt128 or + sbyte or short or int or long + or Int128 => ((IFormattable)value).ToString(null, CultureInfo.InvariantCulture), + DateTime dt => dt.ToString("o"), + DateTimeOffset dto => dto.ToString("o"), + _ => value.ToString() ?? "" + }; + } } From e51e65ee8b7a8ca7fa3fbb7643b2705ed9acccbe Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 5 Jun 2026 17:30:54 +1000 Subject: [PATCH 4/9] Metrics CLI tests --- .../Cli/Commands/Metrics/DimensionsCommand.cs | 6 -- src/SeqCli/Cli/Commands/Node/HealthCommand.cs | 7 +- src/SeqCli/Output/OutputFormat.cs | 4 +- .../Metrics/MetricsCliBasics.cs | 74 +++++++++++++++++++ 4 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs index 51cb9297..89896a32 100644 --- a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs @@ -59,12 +59,6 @@ protected override async Task Run() { try { - if (string.IsNullOrWhiteSpace(_metric)) - { - Log.Error("A `--metric` must be specified"); - return 1; - } - var config = RuntimeConfigurationLoader.Load(_storagePath); var output = _output.GetOutputFormat(config); var connection = SeqConnectionFactory.Connect(_connection, config); diff --git a/src/SeqCli/Cli/Commands/Node/HealthCommand.cs b/src/SeqCli/Cli/Commands/Node/HealthCommand.cs index 6cabd844..54bcf7a9 100644 --- a/src/SeqCli/Cli/Commands/Node/HealthCommand.cs +++ b/src/SeqCli/Cli/Commands/Node/HealthCommand.cs @@ -108,15 +108,16 @@ async Task RunOnce(SeqConnection connection, OutputFormat outputFormat) if (outputFormat.Json) { var shouldBeJson = await response.Content.ReadAsStringAsync(); + object obj; try { - var obj = JsonConvert.DeserializeObject(shouldBeJson) ?? throw new InvalidDataException(); - outputFormat.WriteObject(obj); + obj = JsonConvert.DeserializeObject(shouldBeJson) ?? throw new InvalidDataException(); } catch { - outputFormat.WriteObject(new { Response = shouldBeJson }); + obj = new { Response = shouldBeJson }; } + outputFormat.WriteObject(obj); } else { diff --git a/src/SeqCli/Output/OutputFormat.cs b/src/SeqCli/Output/OutputFormat.cs index f35fcb6b..dbad8667 100644 --- a/src/SeqCli/Output/OutputFormat.cs +++ b/src/SeqCli/Output/OutputFormat.cs @@ -156,7 +156,9 @@ public void WriteObject(object value) } }); - var jo = value is ICollection and not IDictionary ? (JToken)JArray.FromObject(value, settings) : JObject.FromObject(value, settings); + var jo = value is ICollection and not (IDictionary or JToken) ? + (JToken)JArray.FromObject(value, settings) : + JObject.FromObject(value, settings); // Using the same method of JSON colorization as above diff --git a/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs b/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs new file mode 100644 index 00000000..16adf5f5 --- /dev/null +++ b/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +#nullable enable + +namespace SeqCli.EndToEnd.Metrics; + +[CliTestCase(MinimumApiVersion = "2026.1.0")] +class MetricsCliBasics: ICliTestCase +{ + public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + { + await IngestClef(connection, "'a': 1, 'b': 2, '@d': {'a': {'kind': 'Sum', 'description': 'xyz'}}"); + await IngestClef(connection, "'a': 1, 'c': 3, 'd': 4, '@d': {'a': {'kind': 'Sum','description': 'xyz'}}"); + await IngestClef(connection, "'a': 1, 'b': 5, 'e': 6, '@d': {'a': {'kind': 'Sum','description': 'xyz'}, 'e': {'kind': 'Sum', 'description': 'ghi'}}"); + + Assert.Equal(2, SearchResultLines(runner).Count()); + Assert.Equal(4, SearchResultLines(runner, groups: ["b"]).Count()); + Assert.Equal(3, SearchResultLines(runner, filter: "\"xyz\"", groups: ["b"]).Count()); + + Assert.Equal(0, runner.Exec("metrics dimensions")); + var allDimensions = runner.LastRunProcess!.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + Assert.All(["b", "c", "d"], name => Assert.Contains(allDimensions, l => l.Trim() == name)); + + Assert.Equal(0, runner.Exec("metrics dimensions", "--metric e")); + Assert.Equal("b", runner.LastRunProcess!.Output.Trim()); + + Assert.Equal(0, runner.Exec("metrics dimension", "--accessor b")); + var bValues = runner.LastRunProcess!.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + Assert.All(["2", "5"], value => Assert.Contains(bValues, l => l.Trim() == value)); + } + + static IEnumerable SearchResultLines(CliCommandRunner runner, string? filter = null, string[]? groups = null) + { + var args = ""; + if (filter != null) + args += $"--filter=\"{filter.Replace("\"", "\\\"")}\""; + foreach (var group in groups ?? []) + args += $" --group=\"{group.Replace("\"", "\\\"")}\""; + + Assert.Equal(0, runner.Exec("metrics search", args)); + var reader = new StringReader(runner.LastRunProcess!.Output); + var skippedHeading = false; + while (reader.ReadLine() is { } line) + { + if (!skippedHeading) + { + skippedHeading = true; + } + else + { + yield return line!; + } + } + } + + static async Task IngestClef(SeqConnection connection, string fields) + { + var prefix = $"{{\"@t\":\"{DateTime.UtcNow:o}\","; + const string suffix = "}"; + var content = new StringContent($"{prefix}{fields.Replace("'", "\"")}{suffix}"); + var response = await connection.Client.HttpClient.PostAsync("ingest/clef", content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } +} \ No newline at end of file From bf8b46a251fe575846bacfc623d3962142b4ebb8 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 7 Jun 2026 13:55:10 +1000 Subject: [PATCH 5/9] Metrics MCP tools --- src/SeqCli/Cli/Commands/Mcp/RunCommand.cs | 8 +- .../Cli/Commands/Metrics/DimensionsCommand.cs | 2 +- src/SeqCli/Mcp/McpSession.cs | 2 + src/SeqCli/Mcp/Tools/McpResults.cs | 35 ++++ .../Mcp/Tools/Metrics/MetricDefinition.cs | 37 ++++ src/SeqCli/Mcp/Tools/Metrics/MetricsTools.cs | 134 +++++++++++++++ src/SeqCli/Mcp/Tools/Query/QueryTools.cs | 83 +++++++++ ...archAndQueryToolType.cs => SearchTools.cs} | 161 +++--------------- .../{Search => Signals}/SignalSummary.cs | 2 +- src/SeqCli/Mcp/Tools/Signals/SignalTools.cs | 38 +++++ .../Mcp/McpSessionBasicsTestCase.cs | 2 +- .../Mcp/McpSignalUsageTestCase.cs | 3 +- 12 files changed, 368 insertions(+), 139 deletions(-) create mode 100644 src/SeqCli/Mcp/Tools/McpResults.cs create mode 100644 src/SeqCli/Mcp/Tools/Metrics/MetricDefinition.cs create mode 100644 src/SeqCli/Mcp/Tools/Metrics/MetricsTools.cs create mode 100644 src/SeqCli/Mcp/Tools/Query/QueryTools.cs rename src/SeqCli/Mcp/Tools/Search/{SearchAndQueryToolType.cs => SearchTools.cs} (60%) rename src/SeqCli/Mcp/Tools/{Search => Signals}/SignalSummary.cs (96%) create mode 100644 src/SeqCli/Mcp/Tools/Signals/SignalTools.cs diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs index 88d3ab8c..3dd9fbca 100644 --- a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -21,7 +21,10 @@ using SeqCli.Cli.Features; using SeqCli.Config; using SeqCli.Mcp; +using SeqCli.Mcp.Tools.Metrics; +using SeqCli.Mcp.Tools.Query; using SeqCli.Mcp.Tools.Search; +using SeqCli.Mcp.Tools.Signals; using Serilog; namespace SeqCli.Cli.Commands.Mcp; @@ -66,7 +69,10 @@ protected override async Task Run() .AddMcpServer() .WithStdioServerTransport() .WithTools([ - typeof(SearchAndQueryToolType) + typeof(SearchTools), + typeof(MetricsTools), + typeof(QueryTools), + typeof(SignalTools) ]); await builder.Build().RunAsync(); diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs index 89896a32..9f085c28 100644 --- a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs @@ -38,7 +38,7 @@ public DimensionsCommand() { Options.Add( "m=|metric=", - "The metric name, for example `hats-sold` or `http.request.duration`", + "A metric name, for example `hats-sold` or `http.request.duration`; omit to list dimensions for all metrics", v => _metric= v); Options.Add( diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 02606f68..8a102b32 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.cs @@ -25,6 +25,8 @@ namespace SeqCli.Mcp; class McpSession { + public TimeSpan DataToolCallTimeout { get; } = TimeSpan.FromSeconds(45); + readonly Lock _sync = new(); int _nextId = 1; readonly Dictionary _resultIdToEventId = new(); diff --git a/src/SeqCli/Mcp/Tools/McpResults.cs b/src/SeqCli/Mcp/Tools/McpResults.cs new file mode 100644 index 00000000..854b63bc --- /dev/null +++ b/src/SeqCli/Mcp/Tools/McpResults.cs @@ -0,0 +1,35 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using ModelContextProtocol.Protocol; + +namespace SeqCli.Mcp.Tools; + +static class McpResults +{ + public static CallToolResult SimpleText(string resultText, bool isError = false) + { + return new CallToolResult + { + IsError = isError, + Content = + [ + new TextContentBlock + { + Text = resultText + } + ] + }; + } +} \ No newline at end of file diff --git a/src/SeqCli/Mcp/Tools/Metrics/MetricDefinition.cs b/src/SeqCli/Mcp/Tools/Metrics/MetricDefinition.cs new file mode 100644 index 00000000..fe53e115 --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Metrics/MetricDefinition.cs @@ -0,0 +1,37 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using System.ComponentModel; + +namespace SeqCli.Mcp.Tools.Metrics; + +[Description("Describes a metric.")] +class MetricDefinition +{ + [Description("The metric name.")] + public required string Name { get; init; } + + [Description("The metric kind; normally one of `Sum` (counters with delta temporality), `Gauge`, `Fixed` (fixed-bucket histogram), or `Exponential` (exponentially-bucketed histogram).")] + public required string Kind { get; init; } + + [Description("The UCUM unit specified for the metric, if any.")] + public string? Unit { get; init; } + + [Description("A human-readable description of the metric, if available.")] + public string? Description { get; init; } + + [Description("If group keys were specified when retrieving the metric, the values of those group keys for this definition.")] + public List GroupKeyValues { get; init; } = []; +} \ No newline at end of file diff --git a/src/SeqCli/Mcp/Tools/Metrics/MetricsTools.cs b/src/SeqCli/Mcp/Tools/Metrics/MetricsTools.cs new file mode 100644 index 00000000..160978df --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Metrics/MetricsTools.cs @@ -0,0 +1,134 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Seq.Api; +using SeqCli.Output; + +// ReSharper disable UnusedMember.Global + +namespace SeqCli.Mcp.Tools.Metrics; + +[McpServerToolType] +class MetricsTools(McpSession session, SeqConnection connection) +{ + [McpServerTool(Name = "seq_search_metric_definitions", ReadOnly = true, Title = "Search Metric Definitions", UseStructuredContent = true)] + [Description("Search for metric definitions matching given criteria.")] + [return: Description("Matching metric definitions.")] + public async Task SearchMetricsAsync( + [Description("The maximum number of metric definitions to return.")] + [Range(1, 1000)] + int limit, + [Description("A Seq search expression evaluated over metric names (`Keys(@Definitions)[?]`), descriptions " + + "(`@Definitions[?].description`), resource attributes, scope attributes, and raw samples.")] + string? predicate = null, + [Description("Optionally, break down the available descriptions by grouping on one or more resource, scope, or " + + "sample attributes.")] + string[]? groups = null, + CancellationToken cancellationToken = default) + { + if (!string.IsNullOrWhiteSpace(predicate)) + { + if (!predicate.Contains("@Timestamp", StringComparison.OrdinalIgnoreCase)) + { + throw new McpException( + "The predicate doesn't adequately constrain the search range. " + + "To avoid consuming excessive resources, add a time bound such as `@Timestamp >= now() - 4h`."); + } + + var strict = await connection.Expressions.ToStrictAsync(predicate, cancellationToken); + if (strict.MatchedAsText) + { + throw new McpException( + $"The search expression was rejected by the Seq server. {strict.ReasonIfMatchedAsText}"); + } + } + + var definitions = await connection.Metrics.SearchAsync(groups?.ToList() ?? [], predicate, limit, + timeout: session.DataToolCallTimeout, cancellationToken: cancellationToken); + + return definitions.Metrics.Select(m => new MetricDefinition + { + Name = m.Name ?? m.Accessor, + Kind = m.Kind, + Unit = m.Unit, + Description = m.Description, + GroupKeyValues = m.GroupKey + }).ToArray(); + } + + [McpServerTool(Name = "seq_list_metric_dimensions", ReadOnly = true, Title = "List Metric Dimensions")] + [Description("List the dimensions associated with a given metric.")] + [return: Description("Dimension accessor expressions, one per line.")] + public async Task ListDimensionsAsync( + [Description("The maximum number of metric dimensions to return.")] + [Range(1, 1000)] + int limit, + [Description("An ISO 8601 timestamp specifying the lower bound for the search range.")] + DateTimeOffset from, + [Description("The upper bound for the search range.")] + DateTimeOffset to, + [Description("A human-readable metric name, for example `hats-sold` or `http.request.duration`; omit to list dimensions for all metrics.")] + string? metric = null, + CancellationToken cancellationToken = default) + { + var dimensions = await connection.Metrics.ListDimensionsAsync(limit, metric, from.UtcDateTime, to.UtcDateTime, + session.DataToolCallTimeout, cancellationToken: cancellationToken); + + var result = new StringWriter(); + foreach (var dimension in dimensions) + { + await result.WriteLineAsync(dimension.Accessor); + } + + return McpResults.SimpleText(result.ToString()); + } + + [McpServerTool(Name = "seq_list_metric_dimension_values", ReadOnly = true, Title = "List Metric Dimension Values")] + [Description("List the unique values present in a given metric dimension.")] + [return: Description("Dimension values in Seq native syntax, one per line.")] + public async Task ListDimensionValuesAsync( + [Description("The maximum number of values to return.")] + [Range(1, 1000)] + int limit, + [Description("An ISO 8601 timestamp specifying the lower bound for the search range.")] + DateTimeOffset from, + [Description("The upper bound for the search range.")] + DateTimeOffset to, + [Description("The dimension accessor, e.g. `cpu.mode`.")] + string? dimension = null, + CancellationToken cancellationToken = default) + { + var values = await connection.Metrics.ListDimensionValuesAsync(dimension, limit, from.UtcDateTime, to.UtcDateTime, + session.DataToolCallTimeout, cancellationToken: cancellationToken); + + var result = new StringWriter(); + foreach (var value in values) + { + NativeFormatter.WriteValue(result, value); + await result.WriteLineAsync(); + } + + return McpResults.SimpleText(result.ToString()); + } +} diff --git a/src/SeqCli/Mcp/Tools/Query/QueryTools.cs b/src/SeqCli/Mcp/Tools/Query/QueryTools.cs new file mode 100644 index 00000000..11175ac8 --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Query/QueryTools.cs @@ -0,0 +1,83 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.ComponentModel; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Seq.Api; +using Seq.Api.Client; +using Seq.Api.Model.Data; +using Seq.Api.Model.Signals; +using SeqCli.Output; +using SeqCli.Signals; +using Serilog; + +// ReSharper disable UnusedMember.Global + +namespace SeqCli.Mcp.Tools.Query; + +[McpServerToolType] +class QueryTools(McpSession session, SeqConnection connection) +{ + [McpServerTool(Name = "seq_query", ReadOnly = true, Title = "Evaluate a Query over Logs, Spans, or Metric Samples")] + [Description("Evaluate a Seq query, producing tabular results. Use the `seq-search-and-query` " + + "skill when calling this tool.")] + [return: Description("Query results and status information.")] + public async Task QueryAsync( + [Description("A Seq query language query.")] + string query, + [Description("A signal expression restricting the search space. Multiple " + + "signals are intersected with commas, and unioned with tilde, for example, `signal-1,(signal-2~signal-3)`.")] + string? signal = null, + CancellationToken cancellationToken = default) + { + if (query.Contains("from", StringComparison.OrdinalIgnoreCase) && + (!query.Contains("where", StringComparison.OrdinalIgnoreCase) || + !query.Contains("@Timestamp", StringComparison.OrdinalIgnoreCase) && + !query.Contains("@Id", StringComparison.OrdinalIgnoreCase) && + !query.Contains("@TraceId", StringComparison.OrdinalIgnoreCase))) + { + return McpResults.SimpleText("The query doesn't adequately constrain the search range (by `@Timestamp`, `@TraceId`, or `@Id`). " + + "To avoid consuming excessive resources, add a time bound such as `where @Timestamp >= now() - 1d`.", isError: true); + } + + SignalExpressionPart? parsedSignalExpression = null; + if (!string.IsNullOrWhiteSpace(signal)) + parsedSignalExpression = SignalExpressionParser.ParseExpression(signal); + + QueryResultPart result; + try + { + result = await connection.Data.TryQueryAsync(query, signal: parsedSignalExpression, timeout: session.DataToolCallTimeout, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + if (ex.GetBaseException() is not OperationCanceledException) + { + Log.Error(ex, "Exception thrown during query execution"); + } + + var error = ex.GetBaseException() is SeqApiException ? ex.GetBaseException().Message : ex.ToString(); + return McpResults.SimpleText($"The query failed. {error}", isError: true); + } + + var output = new StringWriter(); + NativeFormatter.WriteQueryResult(output, result); + return McpResults.SimpleText(output.ToString(), isError: !string.IsNullOrWhiteSpace(result.Error)); + } +} diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchTools.cs similarity index 60% rename from src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs rename to src/SeqCli/Mcp/Tools/Search/SearchTools.cs index c8f19517..42343c8a 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchTools.cs @@ -24,9 +24,7 @@ using ModelContextProtocol.Server; using Seq.Api; using Seq.Api.Client; -using Seq.Api.Model.Data; using Seq.Api.Model.Events; -using Seq.Api.Model.Expressions; using Seq.Api.Model.Signals; using Seq.Syntax.Templates; using SeqCli.Mapping; @@ -41,14 +39,23 @@ namespace SeqCli.Mcp.Tools.Search; [McpServerToolType] -class SearchAndQueryToolType(McpSession session, SeqConnection connection) +class SearchTools(McpSession session, SeqConnection connection) { const string ResultIdPropertyName = "__seqcli_ResultId"; static readonly ExpressionTemplate SearchResultFormatter = new ( $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}\n{{#if @x is not null}}{{Substring(ToString(@x), 0, 512)}}...\n{{#end}}" ); - [McpServerTool(Name = "seq_search", ReadOnly = true, Title = "Search Events")] + [McpServerTool(Name = "seq_new_session", ReadOnly = true, Title = "Begin a new Search/Query Session")] + [Description("Call this before interacting with Seq tools for the first time (optimizes resource usage by clearing caches).")] + public Task NewSessionAsync(CancellationToken cancellationToken) + { + _ = cancellationToken; + session.Clear(); + return Task.CompletedTask; + } + + [McpServerTool(Name = "seq_search_events", ReadOnly = true, Title = "Search Events")] [Description("Search Seq for log events and spans matching given criteria. Each result is prefixed with " + "a `result_id` of the form `R#####` which is valid in the current MCP session. Individual events can be " + "viewed in full using the `seq_read_search_result` tool. Use the `seq-search-and-query` " + @@ -71,36 +78,14 @@ public async Task SearchEventsAsync( !predicate.Contains("@Id", StringComparison.OrdinalIgnoreCase) && !predicate.Contains("@TraceId", StringComparison.OrdinalIgnoreCase)) { - return SimpleTextResult("The predicate doesn't adequately constrain the search range (by `@Timestamp`, `@TraceId`, or `@Id`). " + + return McpResults.SimpleText("The predicate doesn't adequately constrain the search range (by `@Timestamp`, `@TraceId`, or `@Id`). " + "To avoid consuming excessive resources, add a time bound such as `@Timestamp >= now() - 1d`.", isError: true); } - ExpressionPart strict; - try - { - strict = await connection.Expressions.ToStrictAsync(predicate, cancellationToken); - } - catch (Exception ex) - { - return new CallToolResult - { - IsError = true, - Content = - [ - new TextContentBlock - { - Text = "The Seq API client failed while attempting to validate the search expression." - }, - new TextContentBlock - { - Text = ex.ToString() - } - ], - }; - } + var strict = await connection.Expressions.ToStrictAsync(predicate, cancellationToken); if (strict.MatchedAsText) { - return SimpleTextResult($"The search expression was rejected by the Seq server. {strict.ReasonIfMatchedAsText}", + return McpResults.SimpleText($"The search expression was rejected by the Seq server. {strict.ReasonIfMatchedAsText}", isError: true); } } @@ -112,7 +97,7 @@ public async Task SearchEventsAsync( var resultsLock = new Lock(); string? error = null; var results = new List(); - var timeout = Task.Delay(TimeSpan.FromSeconds(45), cancellationToken); + var timeout = Task.Delay(session.DataToolCallTimeout, cancellationToken); using var cancelEnumerate = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var cancelEnumerateToken = cancelEnumerate.Token; var enumerate = Task.Run(async () => @@ -141,10 +126,9 @@ public async Task SearchEventsAsync( lock (resultsLock) { - error = ex.GetBaseException() is SeqApiException ? ex.GetBaseException().Message : ex.ToString(); + error = ex.GetBaseException() is SeqApiException ? ex.GetBaseException().Message : "The Seq API call failed."; } } - }, cancellationToken); var completed = await Task.WhenAny(enumerate, timeout) == enumerate; @@ -167,25 +151,15 @@ public async Task SearchEventsAsync( } else if (takenError != null) { - if (takenResults.Length == 0) - { - resultSetStatus = $"The search failed. {takenError}"; - } - else - { - resultSetStatus = $"The search failed after retrieving {takenResults.Length} matching event(s). {takenError}"; - } + resultSetStatus = takenResults.Length == 0 ? + $"The search failed. {takenError}" : + $"The search failed after retrieving {takenResults.Length} matching event(s). {takenError}"; } else if (completed) { - if (takenResults.Length == 0) - { - resultSetStatus = "No events matched the search expression."; - } - else - { - resultSetStatus = $"Showing all {takenResults.Length} matching event(s)."; - } + resultSetStatus = takenResults.Length == 0 ? + "No events matched the search expression." : + $"Showing all {takenResults.Length} matching event(s)."; } else { @@ -225,24 +199,24 @@ public async Task SearchEventsAsync( [McpServerTool(Name = "seq_read_search_result", ReadOnly = true, Title = "Read Full Event Details")] - [Description("Read the full details of an event appearing in `seq_search` results, including all property " + + [Description("Read the full details of an event appearing in `seq_search_events` results, including all property " + "values and a complete stack trace (if present). The event is formatted precisely as a Seq syntax literal, " + "using Seq's native data model.")] [return: Description("A Seq-native object literal representation of the event data.")] public Task ReadSearchResultJsonAsync( - [Description("The result id from the `seq_search` tool.")] + [Description("The result id from the `seq_search_events` tool.")] // ReSharper disable once InconsistentNaming string result_id) { if (!session.TryGetSearchResult(result_id, out var result, out var error)) { - return Task.FromResult(SimpleTextResult(error, isError: true)); + return Task.FromResult(McpResults.SimpleText(error, isError: true)); } var resultText = new StringWriter(); NativeFormatter.WriteEvent(resultText, result); - return Task.FromResult(SimpleTextResult(resultText.ToString())); + return Task.FromResult(McpResults.SimpleText(resultText.ToString())); } [McpServerTool(Name = "seq_inspect_result_schema", ReadOnly = true, Title = "Inspect Search Result Schema")] @@ -254,85 +228,4 @@ public Task InspectSchemaAsync(CancellationToken cancellationToken) { return Task.FromResult(session.EnumerateUserPropertyNames(cancellationToken).OrderBy(n => n).ToArray()); } - - [McpServerTool(Name = "seq_query", ReadOnly = true, Title = "Evaluate a Query over Logs, Spans, or Metric Samples")] - [Description("Evaluate a Seq query, producing tabular results. Use the `seq-search-and-query` " + - "skill when calling this tool.")] - [return: Description("Query results and status information.")] - public async Task QueryAsync( - [Description("A Seq query language query.")] - string query, - [Description("A signal expression restricting the search space. Multiple " + - "signals are intersected with commas, and unioned with tilde, for example, `signal-1,(signal-2~signal-3)`.")] - string? signal = null, - CancellationToken cancellationToken = default) - { - if (query.Contains("from", StringComparison.OrdinalIgnoreCase) && - (!query.Contains("where", StringComparison.OrdinalIgnoreCase) || - !query.Contains("@Timestamp", StringComparison.OrdinalIgnoreCase) && - !query.Contains("@Id", StringComparison.OrdinalIgnoreCase) && - !query.Contains("@TraceId", StringComparison.OrdinalIgnoreCase))) - { - return SimpleTextResult("The query doesn't adequately constrain the search range (by `@Timestamp`, `@TraceId`, or `@Id`). " + - "To avoid consuming excessive resources, add a time bound such as `where @Timestamp >= now() - 1d`.", isError: true); - } - - SignalExpressionPart? parsedSignalExpression = null; - if (!string.IsNullOrWhiteSpace(signal)) - parsedSignalExpression = SignalExpressionParser.ParseExpression(signal); - - QueryResultPart result; - try - { - result = await connection.Data.TryQueryAsync(query, signal: parsedSignalExpression, cancellationToken: cancellationToken); - } - catch (Exception ex) - { - if (ex.GetBaseException() is not OperationCanceledException) - { - Log.Error(ex, "Exception thrown during query execution"); - } - - var error = ex.GetBaseException() is SeqApiException ? ex.GetBaseException().Message : ex.ToString(); - return SimpleTextResult($"The query failed. {error}", isError: true); - } - - var output = new StringWriter(); - NativeFormatter.WriteQueryResult(output, result); - return SimpleTextResult(output.ToString(), isError: !string.IsNullOrWhiteSpace(result.Error)); - } - - [McpServerTool(Name = "seq_new_session", ReadOnly = true, Title = "Begin a new Search/Query Session")] - [Description("Call this before interacting with Seq tools for the first time (optimizes resource usage by clearing caches).")] - public Task NewSessionAsync(CancellationToken cancellationToken) - { - _ = cancellationToken; - session.Clear(); - return Task.CompletedTask; - } - - [McpServerTool(Name = "seq_list_signals", ReadOnly = true, Title = "List Signals", UseStructuredContent = true)] - [Description("List available signals. Use signals when searching and querying to efficiently work with well-known " + - "event streams while dramatically improving response times.")] - public async Task ListSignalsAsync(CancellationToken cancellationToken) - { - return (await connection.Signals.ListAsync(shared: true, partial: true, cancellationToken: cancellationToken)) - .Select(s => new SignalSummary { Id = s.Id, Title = s.Title }) - .ToArray(); - } - - static CallToolResult SimpleTextResult(string resultText, bool isError = false) - { - return new CallToolResult - { - IsError = isError, - Content = - [ - new TextContentBlock - { - Text = resultText - } - ] - }; - } -} \ No newline at end of file +} diff --git a/src/SeqCli/Mcp/Tools/Search/SignalSummary.cs b/src/SeqCli/Mcp/Tools/Signals/SignalSummary.cs similarity index 96% rename from src/SeqCli/Mcp/Tools/Search/SignalSummary.cs rename to src/SeqCli/Mcp/Tools/Signals/SignalSummary.cs index 4f1a048f..0f194e47 100644 --- a/src/SeqCli/Mcp/Tools/Search/SignalSummary.cs +++ b/src/SeqCli/Mcp/Tools/Signals/SignalSummary.cs @@ -14,7 +14,7 @@ using System.ComponentModel; -namespace SeqCli.Mcp.Tools.Search; +namespace SeqCli.Mcp.Tools.Signals; [Description("A signal is a saved, indexed filter over log events and spans.")] class SignalSummary diff --git a/src/SeqCli/Mcp/Tools/Signals/SignalTools.cs b/src/SeqCli/Mcp/Tools/Signals/SignalTools.cs new file mode 100644 index 00000000..f1284aa1 --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Signals/SignalTools.cs @@ -0,0 +1,38 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Seq.Api; + +// ReSharper disable UnusedMember.Global + +namespace SeqCli.Mcp.Tools.Signals; + +[McpServerToolType] +class SignalTools(SeqConnection connection) +{ + [McpServerTool(Name = "seq_list_signals", ReadOnly = true, Title = "List Signals", UseStructuredContent = true)] + [Description("List available signals. Use signals when searching and querying to efficiently work with well-known " + + "event streams while dramatically improving response times.")] + public async Task ListSignalsAsync(CancellationToken cancellationToken) + { + return (await connection.Signals.ListAsync(shared: true, partial: true, cancellationToken: cancellationToken)) + .Select(s => new SignalSummary { Id = s.Id, Title = s.Title }) + .ToArray(); + } +} \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs index 080af02f..5ab7e4ae 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs @@ -31,7 +31,7 @@ protected override async Task ExecuteAsync(SeqConnection connection, ILogger log var predicate = $"RunId = '{runId}' and Customer.Tier = 'gold' and @Timestamp >= Now() - 1d"; var searchResult = AssertTextResult(await client.CallToolAsync( - "seq_search", + "seq_search_events", new Dictionary { ["limit"] = 10, ["predicate"] = predicate })); var resultIds = OrderedSearchResultIds(searchResult); Assert.Equal(orders.Count(o => o.Customer.Tier == "gold"), resultIds.Length); diff --git a/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs index f38ad8da..91ed9898 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs @@ -12,6 +12,7 @@ namespace SeqCli.EndToEnd.Mcp; // ReSharper disable once UnusedType.Global public class McpSignalUsageTestCase : McpToolTestCase { + // ReSharper disable once NotAccessedPositionalProperty.Local record SignalSummary(string Id, string Title); // Default signals included in every Seq installation. @@ -56,7 +57,7 @@ protected override async Task ExecuteAsync(SeqConnection connection, ILogger log static async Task CountSearchResultsAsync(McpClient client, string predicate, string signal) { var searchResult = AssertTextResult(await client.CallToolAsync( - "seq_search", + "seq_search_events", new Dictionary { ["limit"] = 10, ["predicate"] = predicate, ["signal"] = signal })); return OrderedSearchResultIds(searchResult).Length; } From cae41fedd286ed93deb37e5b0cf3043476c70672 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 7 Jun 2026 20:01:59 +1000 Subject: [PATCH 6/9] Port metrics CLI tests to cover the metrics MCP tools. Groups argument passing is non-working, and when hard-coded through, uncovers a panic in metric buffer search. Assisted-by: Claude Opus 4.8 --- .../Mcp/McpMetricsBasicsTestCase.cs | 75 +++++++++++++++++++ test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs | 2 + .../Metrics/MetricsCliBasics.cs | 17 +---- .../Support/DirectIngestion.cs | 22 ++++++ 4 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 test/SeqCli.EndToEnd/Mcp/McpMetricsBasicsTestCase.cs create mode 100644 test/SeqCli.EndToEnd/Support/DirectIngestion.cs diff --git a/test/SeqCli.EndToEnd/Mcp/McpMetricsBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpMetricsBasicsTestCase.cs new file mode 100644 index 00000000..6ddd03ad --- /dev/null +++ b/test/SeqCli.EndToEnd/Mcp/McpMetricsBasicsTestCase.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using JetBrains.Annotations; +using ModelContextProtocol.Client; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +#nullable enable + +namespace SeqCli.EndToEnd.Mcp; + +//[CliTestCase(MinimumApiVersion = "2026.1.0")] +public class McpMetricsBasicsTestCase : McpToolTestCase +{ + [UsedImplicitly] + record MetricDefinition(string Name, string Kind, string? Unit, string? Description); + + protected override async Task ExecuteAsync(SeqConnection connection, ILogger logger, McpClient client) + { + await DirectIngestion.IngestClef(connection, "'a': 1, 'b': 2, '@d': {'a': {'kind': 'Sum', 'description': 'xyz'}}"); + await DirectIngestion.IngestClef(connection, "'a': 1, 'c': 3, 'd': 4, '@d': {'a': {'kind': 'Sum','description': 'xyz'}}"); + await DirectIngestion.IngestClef(connection, "'a': 1, 'b': 5, 'e': 6, '@d': {'a': {'kind': 'Sum','description': 'xyz'}, 'e': {'kind': 'Sum', 'description': 'ghi'}}"); + + var allMetrics = AssertStructuredResult(await client.CallToolAsync( + "seq_search_metric_definitions", + new Dictionary { ["limit"] = 100 })); + Assert.Equal(2, allMetrics.Length); + + var groupedByB = AssertStructuredResult(await client.CallToolAsync( + "seq_search_metric_definitions", + new Dictionary { ["limit"] = 100, ["groups"] = (string[])["b"] })); + Assert.Equal(4, groupedByB.Length); + + var filteredByDescription = AssertStructuredResult(await client.CallToolAsync( + "seq_search_metric_definitions", + new Dictionary + { + ["limit"] = 100, + ["predicate"] = "\"xyz\" and @Timestamp >= Now() - 1d", + ["groups"] = (string[])["b"] + })); + Assert.Equal(3, filteredByDescription.Length); + + var from = DateTimeOffset.UtcNow.AddDays(-1).ToString("o"); + var to = DateTimeOffset.UtcNow.AddDays(1).ToString("o"); + var allDimensions = TextLines(AssertTextResult(await client.CallToolAsync( + "seq_list_metric_dimensions", + new Dictionary { ["limit"] = 100, ["from"] = from, ["to"] = to }))); + Assert.All(["b", "c", "d"], name => Assert.Contains(name, allDimensions)); + + var dimensionsForE = AssertTextResult(await client.CallToolAsync( + "seq_list_metric_dimensions", + new Dictionary { ["limit"] = 100, ["from"] = from, ["to"] = to, ["metric"] = "e" })); + Assert.Equal("b", dimensionsForE.Trim()); + + var bValues = TextLines(AssertTextResult(await client.CallToolAsync( + "seq_list_metric_dimension_values", + new Dictionary { ["limit"] = 100, ["from"] = from, ["to"] = to, ["dimension"] = "b" }))); + Assert.All(["2", "5"], value => Assert.Contains(value, bValues)); + + var unbounded = await client.CallToolAsync( + "seq_search_metric_definitions", + new Dictionary { ["limit"] = 100, ["predicate"] = "\"xyz\"" }); + Assert.True(unbounded.IsError ?? false); + } + + static string[] TextLines(string text) + { + return text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } +} diff --git a/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs index ecd633fc..e713c018 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; +using JetBrains.Annotations; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using Seq.Api; @@ -15,6 +16,7 @@ namespace SeqCli.EndToEnd.Mcp; /// Base class for test cases exercising the tools provided by seqcli mcp run. The MCP server /// is spawned over stdio and supplied to the subclass as a connected . /// +[UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)] public abstract partial class McpToolTestCase : ICliTestCase { public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) diff --git a/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs b/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs index 16adf5f5..b134cdcc 100644 --- a/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs +++ b/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs @@ -2,8 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net; -using System.Net.Http; using System.Threading.Tasks; using Seq.Api; using SeqCli.EndToEnd.Support; @@ -19,9 +17,9 @@ class MetricsCliBasics: ICliTestCase { public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) { - await IngestClef(connection, "'a': 1, 'b': 2, '@d': {'a': {'kind': 'Sum', 'description': 'xyz'}}"); - await IngestClef(connection, "'a': 1, 'c': 3, 'd': 4, '@d': {'a': {'kind': 'Sum','description': 'xyz'}}"); - await IngestClef(connection, "'a': 1, 'b': 5, 'e': 6, '@d': {'a': {'kind': 'Sum','description': 'xyz'}, 'e': {'kind': 'Sum', 'description': 'ghi'}}"); + await DirectIngestion.IngestClef(connection, "'a': 1, 'b': 2, '@d': {'a': {'kind': 'Sum', 'description': 'xyz'}}"); + await DirectIngestion.IngestClef(connection, "'a': 1, 'c': 3, 'd': 4, '@d': {'a': {'kind': 'Sum','description': 'xyz'}}"); + await DirectIngestion.IngestClef(connection, "'a': 1, 'b': 5, 'e': 6, '@d': {'a': {'kind': 'Sum','description': 'xyz'}, 'e': {'kind': 'Sum', 'description': 'ghi'}}"); Assert.Equal(2, SearchResultLines(runner).Count()); Assert.Equal(4, SearchResultLines(runner, groups: ["b"]).Count()); @@ -62,13 +60,4 @@ static IEnumerable SearchResultLines(CliCommandRunner runner, string? fi } } } - - static async Task IngestClef(SeqConnection connection, string fields) - { - var prefix = $"{{\"@t\":\"{DateTime.UtcNow:o}\","; - const string suffix = "}"; - var content = new StringContent($"{prefix}{fields.Replace("'", "\"")}{suffix}"); - var response = await connection.Client.HttpClient.PostAsync("ingest/clef", content); - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - } } \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Support/DirectIngestion.cs b/test/SeqCli.EndToEnd/Support/DirectIngestion.cs new file mode 100644 index 00000000..32a32d2e --- /dev/null +++ b/test/SeqCli.EndToEnd/Support/DirectIngestion.cs @@ -0,0 +1,22 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Seq.Api; +using Xunit; + +namespace SeqCli.EndToEnd.Support; + +public static class DirectIngestion +{ + // In questionable taste, but very handy, `fields` carries the comma-separated `'key': value` pairs massaged + // into JSON by replacing `'` with `"`. + public static async Task IngestClef(SeqConnection connection, string fields) + { + var prefix = $"{{\"@t\":\"{DateTime.UtcNow:o}\","; + const string suffix = "}"; + var content = new StringContent($"{prefix}{fields.Replace("'", "\"")}{suffix}"); + var response = await connection.Client.HttpClient.PostAsync("ingest/clef", content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } +} \ No newline at end of file From 6e01fc8a55b06fb785afd7f5f6db5e1b88e62e9b Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Mon, 8 Jun 2026 10:30:27 +1000 Subject: [PATCH 7/9] Fix string[] parameter binding --- src/SeqCli/Cli/Commands/Mcp/RunCommand.cs | 19 ++++++++++++++++++- src/SeqCli/SeqCli.csproj | 19 ++++++------------- .../Properties/launchSettings.json | 4 ++++ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs index 3dd9fbca..d9d024eb 100644 --- a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -14,6 +14,10 @@ using System; using System.Threading.Tasks; +using Autofac; +using Autofac.Builder; +using Autofac.Core; +using Autofac.Core.Resolving.Pipeline; using Autofac.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -61,7 +65,20 @@ protected override async Task Run() try { var builder = Host.CreateApplicationBuilder(); - builder.ConfigureContainer(new AutofacServiceProviderFactory()); + builder.ConfigureContainer(new AutofacServiceProviderFactory(container => + { + // The MCP SDK tries to use the container to resolve any parameter type; Autofac's default collection + // registrations cause array parameters to resolve to empty arrays. We thwart this by short-circuiting + // the search for matching registrations. + var stringArray = new TypedService(typeof(string[])); + container.RegisterServiceMiddleware(PipelinePhase.ResolveRequestStart, (rr, ctx) => + { + if (rr.Service == stringArray) + return; + + ctx(rr); + }); + })); builder.Services.AddSerilog(); builder.Services.AddSingleton(_ => SeqConnectionFactory.Connect(_connection, config)); builder.Services.AddSingleton(); diff --git a/src/SeqCli/SeqCli.csproj b/src/SeqCli/SeqCli.csproj index 4344de70..7864a99c 100644 --- a/src/SeqCli/SeqCli.csproj +++ b/src/SeqCli/SeqCli.csproj @@ -19,19 +19,12 @@ true - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - + + + + + + diff --git a/test/SeqCli.EndToEnd/Properties/launchSettings.json b/test/SeqCli.EndToEnd/Properties/launchSettings.json index 0e1d1c7e..a535203b 100644 --- a/test/SeqCli.EndToEnd/Properties/launchSettings.json +++ b/test/SeqCli.EndToEnd/Properties/launchSettings.json @@ -15,6 +15,10 @@ "SeqCli.EndToEnd (datalust/seq:preview)": { "commandName": "Project", "commandLineArgs": "--docker-server --pre" + }, + "SeqCli.EndToEnd (McpMetrics*)": { + "commandName": "Project", + "commandLineArgs": "--docker-server --pre McpMetrics" } } } From 7f95d887094834f99ba20402126205e5bc143d11 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Mon, 8 Jun 2026 10:38:14 +1000 Subject: [PATCH 8/9] Invoke Deserialize() as an extension method --- test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs index e713c018..b91697c7 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs @@ -50,7 +50,7 @@ protected static T AssertStructuredResult(CallToolResult callToolResult) // Tools returning non-object values have them wrapped in a `result` property by the MCP // SDK, because the protocol requires `structuredContent` to be an object. var result = callToolResult.StructuredContent.Value.GetProperty("result"); - return JsonSerializer.Deserialize(result, JsonSerializerOptions.Web)!; + return result.Deserialize(JsonSerializerOptions.Web)!; } protected static string[] OrderedSearchResultIds(string searchResult) From 46e6cad5bb45d04ddc7ae005a98ba9f5e86ee29c Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Mon, 8 Jun 2026 11:00:34 +1000 Subject: [PATCH 9/9] Quick README fix for non-pre Forwarder commands --- README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b855a58f..60aca3dc 100644 --- a/README.md +++ b/README.md @@ -1868,7 +1868,7 @@ PS > seqcli signal list -i signal-m33302 --json {"Title": "Alarms", "Description": "Automatically created", "Filters": [{"De... ``` -## Store-and-forward ingestion proxy (preview) +## Store-and-forward ingestion proxy The `seqcli forwarder` family of commands provide simple, durable ingestion buffering for occasionally-connected and intermittently-disconnected systems. The forwarder implements the Seq ingestion API, so applications that write @@ -1890,12 +1890,9 @@ destination Seq server. To start a forwarder instance at the terminal, listening on port 5341 and forwarding to `seq.example.com`, run: ```shell -seqcli forwarder run --pre --listen http://127.0.0.1:5341 -s https://seq.example.com +seqcli forwarder run --listen http://127.0.0.1:5341 -s https://seq.example.com ``` -> While the `forwarder` command group is in preview, all `forwarder` commands require the `--pre` switch; you'll -> also need to supply `--pre` when requesting help, e.g. `seqcli help forwarder run --pre`. - You can test your forwarder using the `seqcli log` command: ```shell