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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,29 @@ dotnet publish -c Release -r win-x64 -p:PublishSingleFile=true

The following tools are available through the MCP protocol:

- **`SeqSearch`** - Search Seq events with filters
- Parameters:
- **`SeqSearch`** - Search Seq events with filters, date ranges, signals, and pagination
- Parameters:
- `filter` (required): Seq filter expression (use empty string `""` for all events)
- `count`: Number of events to return (default: 100)
- `count`: Number of events to return (default: 100, max: 1000)
- `signalId` (optional): Signal ID to filter events (use `SignalList` to find IDs)
- `fromDateUtc` (optional): Earliest date/time (ISO 8601, e.g., `"2024-01-01T00:00:00Z"`)
- `toDateUtc` (optional): Latest date/time (ISO 8601, e.g., `"2024-01-31T23:59:59Z"`)
- `afterId` (optional): Event ID to search after (exclusive) - use for pagination
- `timeoutSeconds` (optional): Timeout in seconds (1-300)
- `workspace` (optional): Specific workspace to query
- Returns: List of matching events
- Returns: List of matching events (ordered least to most recent)
- **Note:** For date filtering, use `fromDateUtc`/`toDateUtc` parameters instead of `@Timestamp` in the filter expression for better performance
- **Pagination:** To fetch more than 1000 events, use `afterId` with the ID of the last event from the previous search
- Example filters:
- `""` - all events
- `"error"` - events containing "error"
- `@Level = "Error"` - error level events
- `Application = "MyApp"` - events from specific application
- Example with date range:
- `filter: "@Level = 'Error'", fromDateUtc: "2024-01-01T00:00:00Z", toDateUtc: "2024-01-31T23:59:59Z"`
- Example with pagination:
- First call: `filter: "", count: 1000` → returns events with IDs
- Second call: `filter: "", count: 1000, afterId: "event-<last-id>"` → returns next batch

- **`SeqWaitForEvents`** - Wait for and capture live events from Seq (5-second timeout)
- Parameters:
Expand Down
105 changes: 96 additions & 9 deletions src/SeqMcpServer/Mcp/SeqTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,42 +13,129 @@ namespace SeqMcpServer.Mcp;
[McpServerToolType]
public static class SeqTools
{
/// <summary>
/// Normalize common filter patterns to Seq's expected format.
/// </summary>
private static string NormalizeFilter(string filter)
{
if (string.IsNullOrWhiteSpace(filter) || filter.Trim() == "*")
{
return string.Empty; // Empty string means "all events" in Seq
}
return filter;
}
/// <summary>
/// Search historical events in Seq with the specified filter.
/// </summary>
/// <param name="fac">Factory for creating Seq connections</param>
/// <param name="filter">Seq filter expression (e.g., "@Level = 'Error'")</param>
/// <param name="filter">Seq filter expression (e.g., "@Level = 'Error'"). Note: Use fromDateUtc/toDateUtc parameters for date filtering instead of @Timestamp in the filter expression for better performance.</param>
/// <param name="count">Maximum number of events to return (1-1000)</param>
/// <param name="signalId">Optional signal ID to filter events (use SignalList to find available signal IDs)</param>
/// <param name="fromDateUtc">Optional earliest date/time (ISO 8601 format, e.g., '2024-01-01T00:00:00Z'). Use this instead of @Timestamp in filter for better performance.</param>
/// <param name="toDateUtc">Optional latest date/time (ISO 8601 format, e.g., '2024-01-31T23:59:59Z'). Use this instead of @Timestamp in filter for better performance.</param>
/// <param name="afterId">Optional event ID to search after (exclusive). Use for pagination - pass the ID of the last event from the previous search to get the next batch.</param>
/// <param name="timeoutSeconds">Optional timeout in seconds (1-300). If not specified, uses the default cancellation token.</param>
/// <param name="workspace">Optional workspace identifier for multi-tenant scenarios</param>
/// <param name="ct">Cancellation token</param>
/// <returns>List of matching events</returns>
[McpServerTool, Description("Search Seq events with filters, returning up to the specified count")]
[McpServerTool, Description("Search Seq events with filters, date ranges, signals, pagination, and optional timeout. For date filtering, use fromDateUtc/toDateUtc parameters instead of @Timestamp in the filter expression. For pagination, use afterId with the last event ID from previous results.")]
public static async Task<List<EventEntity>> SeqSearch(
SeqConnectionFactory fac,
[Required] string filter,
[Range(1, 1000)] int count = 100,
string? signalId = null,
string? fromDateUtc = null,
string? toDateUtc = null,
string? afterId = null,
[Range(1, 300)] int? timeoutSeconds = null,
string? workspace = null,
CancellationToken ct = default)
{
try
{
var conn = fac.Create(workspace);
var events = new List<EventEntity>();
await foreach (var evt in conn.Events.EnumerateAsync(
filter: filter,
count: count,
render: true,
cancellationToken: ct).WithCancellation(ct))

// Normalize filter (e.g., "*" becomes empty string for "all events")
filter = NormalizeFilter(filter);

// Parse date parameters if provided
DateTime? fromDate = null;
DateTime? toDate = null;

if (!string.IsNullOrEmpty(fromDateUtc))
{
events.Add(evt);
if (!DateTime.TryParse(fromDateUtc, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
{
throw new ArgumentException($"Invalid fromDateUtc format: {fromDateUtc}. Use ISO 8601 format (e.g., '2024-01-01T00:00:00Z')");
}
fromDate = parsed.ToUniversalTime();
}

if (!string.IsNullOrEmpty(toDateUtc))
{
if (!DateTime.TryParse(toDateUtc, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
{
throw new ArgumentException($"Invalid toDateUtc format: {toDateUtc}. Use ISO 8601 format (e.g., '2024-01-31T23:59:59Z')");
}
toDate = parsed.ToUniversalTime();
}

// Fetch signal entity if signal ID is provided
SignalEntity? signalEntity = null;
if (!string.IsNullOrEmpty(signalId))
{
signalEntity = await conn.Signals.FindAsync(signalId, cancellationToken: ct);
if (signalEntity == null)
{
throw new ArgumentException($"Signal with ID '{signalId}' not found. Use SignalList to find available signals.");
}
}

// Create timeout cancellation token if specified
CancellationTokenSource? timeoutCts = null;
CancellationTokenSource? combinedCts = null;

try
{
if (timeoutSeconds.HasValue)
{
timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds.Value));
combinedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
ct = combinedCts.Token;
}

await foreach (var evt in conn.Events.EnumerateAsync(
unsavedSignal: signalEntity,
filter: filter,
count: count,
afterId: afterId,
fromDateUtc: fromDate,
toDateUtc: toDate,
render: true,
cancellationToken: ct).WithCancellation(ct))
{
events.Add(evt);
}

return events;
}
finally
{
combinedCts?.Dispose();
timeoutCts?.Dispose();
}
return events;
}
catch (OperationCanceledException)
{
// Return empty list on cancellation
return [];
}
catch (Seq.Api.Client.SeqApiException ex) when (ex.Message.Contains("Syntax error"))
{
// Provide a more helpful error message for filter syntax errors
throw new ArgumentException($"Invalid filter expression: {ex.Message}. Use an empty string \"\" for all events, or a valid Seq filter expression like \"@Level = 'Error'\".", ex);
}
catch (Exception)
{
// Re-throw to let MCP handle the error
Expand Down
188 changes: 188 additions & 0 deletions tests/SeqMcpServer.Tests/McpToolsIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,192 @@ public async Task MCP_Client_CanListTools()
Assert.Contains(tools, t => t.Name == "SignalList");
Assert.Equal(3, tools.Count);
}

[Fact]
public async Task SeqSearch_WithDateRange_ReturnsFilteredEvents()
{
// Arrange - Set up date range (last 7 days to now)
var fromDate = DateTime.UtcNow.AddDays(-7).ToString("o");
var toDate = DateTime.UtcNow.ToString("o");

// Act - Call SeqSearch with date range
var result = await _mcpClient!.CallToolAsync(
"SeqSearch",
new Dictionary<string, object?>
{
["filter"] = "*",
["count"] = 10,
["fromDateUtc"] = fromDate,
["toDateUtc"] = toDate
});

// Assert - Should return valid result
Assert.NotNull(result);
Assert.NotNull(result.Content);
// Note: May be empty if no events in date range
}

[Fact]
public async Task SeqSearch_WithTimeout_ReturnsBeforeTimeout()
{
// Act - Call SeqSearch with a reasonable timeout
var result = await _mcpClient!.CallToolAsync(
"SeqSearch",
new Dictionary<string, object?>
{
["filter"] = "*",
["count"] = 10,
["timeoutSeconds"] = 30
});

// Assert - Should return valid result before timeout
Assert.NotNull(result);
Assert.NotNull(result.Content);
}

[Fact]
public async Task SeqSearch_WithInvalidDateFormat_ReturnsError()
{
// Act - Call SeqSearch with invalid date format
var result = await _mcpClient!.CallToolAsync(
"SeqSearch",
new Dictionary<string, object?>
{
["filter"] = "*",
["count"] = 10,
["fromDateUtc"] = "not-a-date"
});

// Assert - Should indicate an error occurred
Assert.NotNull(result);
Assert.True(result.IsError, "Expected IsError to be true for invalid date format");
Assert.NotNull(result.Content);
Assert.True(result.Content.Any(), "Expected error content to be present");
}

[Fact]
public async Task SeqSearch_WithInvalidSignalId_ReturnsError()
{
// Act - Call SeqSearch with non-existent signal ID
var result = await _mcpClient!.CallToolAsync(
"SeqSearch",
new Dictionary<string, object?>
{
["filter"] = "*",
["count"] = 10,
["signalId"] = "signal-nonexistent-12345"
});

// Assert - Should indicate an error occurred
Assert.NotNull(result);
Assert.True(result.IsError, "Expected IsError to be true for invalid signal ID");
Assert.NotNull(result.Content);
Assert.True(result.Content.Any(), "Expected error content to be present");
}

[Fact]
public async Task SeqSearch_WithVeryShortTimeout_HandlesGracefully()
{
// Act - Call SeqSearch with a very short timeout (1 second)
var result = await _mcpClient!.CallToolAsync(
"SeqSearch",
new Dictionary<string, object?>
{
["filter"] = "*",
["count"] = 1000, // Large count that might take time
["timeoutSeconds"] = 1
});

// Assert - Should return successfully (may be empty list if timeout)
Assert.NotNull(result);
Assert.NotNull(result.Content);
// Content may be empty if timeout occurred
}

[Fact]
public async Task SeqSearch_WithAfterId_ReturnsPaginatedResults()
{
// Arrange - First, get initial results to get an event ID
var firstResult = await _mcpClient!.CallToolAsync(
"SeqSearch",
new Dictionary<string, object?>
{
["filter"] = "*",
["count"] = 5
});

Assert.NotNull(firstResult);
Assert.NotNull(firstResult.Content);

// If we have events, test pagination with afterId
if (firstResult.Content.Any())
{
// Parse the first result to extract event IDs
// Note: This is a simplified test - in a real scenario we'd parse the JSON
// For now, just test that the parameter is accepted
var secondResult = await _mcpClient!.CallToolAsync(
"SeqSearch",
new Dictionary<string, object?>
{
["filter"] = "*",
["count"] = 5,
["afterId"] = "event-test-id" // Using a test ID
});

// Assert - Should accept the parameter without error
Assert.NotNull(secondResult);
Assert.NotNull(secondResult.Content);
}
}

[Fact]
public async Task SeqSearch_WithAsteriskFilter_NormalizesToEmptyString()
{
// Act - Search with "*" which should be normalized to empty string
var result = await _mcpClient!.CallToolAsync(
"SeqSearch",
new Dictionary<string, object?>
{
["filter"] = "*",
["count"] = 5
});

// Assert - Should succeed (filter normalized to empty string) or return specific error
Assert.NotNull(result);
Assert.NotNull(result.Content);
// With filter normalization, "*" should work. If it fails, check error message
if (result.IsError)
{
var errorJson = JsonSerializer.Serialize(result.Content.First());
// Should either work (IsError=false) or give a helpful error about syntax
Assert.True(errorJson.Contains("Syntax error") || errorJson.Contains("Invalid filter"));
}
}

[Fact]
public async Task SeqSearch_WithInvalidFilterSyntax_ReturnsHelpfulError()
{
// Act - Search with invalid filter syntax
var result = await _mcpClient!.CallToolAsync(
"SeqSearch",
new Dictionary<string, object?>
{
["filter"] = "@Level = = 'Error'", // Invalid syntax (double =)
["count"] = 5
});

// Assert - Should return error with some helpful message
Assert.NotNull(result);
Assert.True(result.IsError);
Assert.NotNull(result.Content);
Assert.True(result.Content.Any());

var errorJson = JsonSerializer.Serialize(result.Content.First());
// Should contain either our improved error message or at least mention an error occurred
Assert.True(
errorJson.Contains("Invalid filter expression") ||
errorJson.Contains("Syntax error") ||
errorJson.Contains("An error occurred"),
"Error message should indicate filter syntax problem");
}
}