Skip to content

Commit 26935ef

Browse files
authored
Merge pull request #2 from matt-richardson/add-seq-convert-filter
Add SeqConvertFilter tool for fuzzy-to-strict filter conversion
2 parents cee36b0 + a1b7812 commit 26935ef

3 files changed

Lines changed: 106 additions & 3 deletions

File tree

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,18 @@ The following tools are available through the MCP protocol:
9898
- Returns: Snapshot of events captured during the wait period (may be empty if no events match)
9999

100100
- **`SignalList`** - List available signals (read-only)
101-
- Parameters:
101+
- Parameters:
102102
- `workspace` (optional): Specific workspace to query
103103
- Returns: List of signals with their definitions
104104

105+
- **`SeqConvertFilter`** - Convert fuzzy filter to strict filter expression
106+
- Parameters:
107+
- `fuzzyFilter` (required): Fuzzy search text (e.g., "error", "timeout")
108+
- `workspace` (optional): Specific workspace to query
109+
- Returns: Strict Seq filter expression for use in `SeqSearch`
110+
- Use case: Help users write correct filter expressions
111+
- Example: Convert "error" to a proper Seq filter expression
112+
105113
## Claude Desktop Integration
106114

107115
### Option 1: Using .NET Global Tool (Recommended)

src/SeqMcpServer/Mcp/SeqTools.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,21 @@
44
using SeqMcpServer.Services;
55
using System.ComponentModel;
66
using System.ComponentModel.DataAnnotations;
7+
using System.Text.Json.Serialization;
78

89
namespace SeqMcpServer.Mcp;
910

11+
/// <summary>
12+
/// Result of converting a fuzzy filter expression to a strict Seq filter expression.
13+
/// </summary>
14+
/// <param name="StrictExpression">The strict filter expression (the original input if no conversion was needed).</param>
15+
/// <param name="MatchedAsText">True if Seq interpreted the input as a free-text search rather than a filter expression.</param>
16+
/// <param name="ReasonIfMatchedAsText">Explanation of why the input was interpreted as text, or null when it wasn't.</param>
17+
public sealed record SeqConvertFilterResult(
18+
[property: JsonPropertyName("strictExpression")] string StrictExpression,
19+
[property: JsonPropertyName("matchedAsText")] bool MatchedAsText,
20+
[property: JsonPropertyName("reasonIfMatchedAsText"), JsonIgnore(Condition = JsonIgnoreCondition.Never)] string? ReasonIfMatchedAsText);
21+
1022
/// <summary>
1123
/// MCP tools for interacting with Seq structured logging server.
1224
/// </summary>
@@ -173,4 +185,49 @@ public static async Task<List<SignalEntity>> SignalList(
173185
throw;
174186
}
175187
}
188+
189+
/// <summary>
190+
/// Convert a fuzzy filter expression to a strict Seq filter expression.
191+
/// </summary>
192+
/// <remarks>
193+
/// Seq supports both "fuzzy" filters (like typing in the UI search box) and "strict" filters
194+
/// (formal filter expressions). This tool converts fuzzy filters to strict ones, helping users
195+
/// write correct filter expressions. For example, "error" becomes a proper filter expression.
196+
/// The result includes whether the filter was interpreted as a text search and the reason if so.
197+
/// </remarks>
198+
/// <param name="fac">Factory for creating Seq connections</param>
199+
/// <param name="fuzzyFilter">The fuzzy filter expression to convert (e.g., "error", "timeout")</param>
200+
/// <param name="workspace">Optional workspace identifier for multi-tenant scenarios</param>
201+
/// <param name="ct">Cancellation token</param>
202+
/// <returns>Conversion result including the strict expression and metadata about the conversion</returns>
203+
[McpServerTool, Description("Convert a fuzzy filter expression to a strict Seq filter expression. Helps write correct filters for SeqSearch.")]
204+
public static async Task<SeqConvertFilterResult> SeqConvertFilter(
205+
SeqConnectionFactory fac,
206+
[Required] string fuzzyFilter,
207+
string? workspace = null,
208+
CancellationToken ct = default)
209+
{
210+
try
211+
{
212+
var conn = fac.Create(workspace);
213+
var result = await conn.Expressions.ToStrictAsync(fuzzyFilter, cancellationToken: ct);
214+
215+
if (result == null)
216+
{
217+
return new SeqConvertFilterResult(fuzzyFilter, false, null);
218+
}
219+
220+
return new SeqConvertFilterResult(
221+
result.StrictExpression ?? fuzzyFilter,
222+
result.MatchedAsText,
223+
result.ReasonIfMatchedAsText);
224+
}
225+
catch (Exception)
226+
{
227+
// Re-throw to let MCP handle the error. Unlike the list-returning tools in this
228+
// file, there is no unambiguous "empty" SeqConvertFilterResult value, so cancellation
229+
// also propagates rather than being swallowed.
230+
throw;
231+
}
232+
}
176233
}

tests/SeqMcpServer.Tests/McpToolsIntegrationTests.cs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Text.Json;
12
using DotNet.Testcontainers.Builders;
23
using DotNet.Testcontainers.Containers;
34
using Microsoft.Extensions.Configuration;
@@ -240,12 +241,49 @@ protected async Task MCP_Client_CanListTools_Core()
240241
// Act - List available tools via MCP
241242
var tools = await McpClient.ListToolsAsync();
242243

243-
// Assert - Should have our three tools
244+
// Assert - Should have our four tools
244245
Assert.NotNull(tools);
245246
Assert.Contains(tools, t => t.Name == "SeqSearch");
246247
Assert.Contains(tools, t => t.Name == "SeqWaitForEvents");
247248
Assert.Contains(tools, t => t.Name == "SignalList");
248-
Assert.Equal(3, tools.Count);
249+
Assert.Contains(tools, t => t.Name == "SeqConvertFilter");
250+
Assert.Equal(4, tools.Count);
251+
}
252+
253+
[Theory]
254+
// Bareword and invalid syntax fall back to text-match
255+
[InlineData("error", "\"error\"", true)]
256+
[InlineData("error timeout", "\"error timeout\"", true)]
257+
// Already-strict filter expressions pass through unchanged
258+
[InlineData("@Level = 'Error'", "@Level = 'Error'", false)]
259+
[InlineData("Application = 'foo'", "Application = 'foo'", false)]
260+
// Fuzzy-but-valid expression gets normalized (== becomes =)
261+
[InlineData("User=='alice'", "User = 'alice'", false)]
262+
public async Task SeqConvertFilter_ReturnsExpectedResult(string fuzzyFilter, string expectedStrict, bool expectedMatchedAsText)
263+
{
264+
var result = await _mcpClient!.CallToolAsync(
265+
"SeqConvertFilter",
266+
new Dictionary<string, object?>
267+
{
268+
["fuzzyFilter"] = fuzzyFilter
269+
});
270+
271+
Assert.NotNull(result);
272+
Assert.NotNull(result.Content);
273+
Assert.True(result.Content.Any());
274+
Assert.False(result.IsError);
275+
276+
var contentJson = JsonSerializer.Serialize(result.Content.First());
277+
using var outer = JsonDocument.Parse(contentJson);
278+
var innerText = outer.RootElement.GetProperty("text").GetString();
279+
Assert.NotNull(innerText);
280+
using var inner = JsonDocument.Parse(innerText);
281+
282+
Assert.Equal(expectedStrict, inner.RootElement.GetProperty("strictExpression").GetString());
283+
Assert.Equal(expectedMatchedAsText, inner.RootElement.GetProperty("matchedAsText").GetBoolean());
284+
285+
var reason = inner.RootElement.GetProperty("reasonIfMatchedAsText").GetString();
286+
Assert.Equal(expectedMatchedAsText, !string.IsNullOrEmpty(reason));
249287
}
250288

251289
private async Task WriteTestEventAsync(string messageTemplate, string propertyName, bool propertyValue)

0 commit comments

Comments
 (0)