diff --git a/README.md b/README.md index 61d86be..42d6526 100644 --- a/README.md +++ b/README.md @@ -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-"` → returns next batch - **`SeqWaitForEvents`** - Wait for and capture live events from Seq (5-second timeout) - Parameters: diff --git a/src/SeqMcpServer/Mcp/SeqTools.cs b/src/SeqMcpServer/Mcp/SeqTools.cs index 2bc6962..84bfab2 100644 --- a/src/SeqMcpServer/Mcp/SeqTools.cs +++ b/src/SeqMcpServer/Mcp/SeqTools.cs @@ -13,20 +13,41 @@ namespace SeqMcpServer.Mcp; [McpServerToolType] public static class SeqTools { + /// + /// Normalize common filter patterns to Seq's expected format. + /// + private static string NormalizeFilter(string filter) + { + if (string.IsNullOrWhiteSpace(filter) || filter.Trim() == "*") + { + return string.Empty; // Empty string means "all events" in Seq + } + return filter; + } /// /// Search historical events in Seq with the specified filter. /// /// Factory for creating Seq connections - /// Seq filter expression (e.g., "@Level = 'Error'") + /// 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. /// Maximum number of events to return (1-1000) + /// Optional signal ID to filter events (use SignalList to find available signal IDs) + /// Optional earliest date/time (ISO 8601 format, e.g., '2024-01-01T00:00:00Z'). Use this instead of @Timestamp in filter for better performance. + /// Optional latest date/time (ISO 8601 format, e.g., '2024-01-31T23:59:59Z'). Use this instead of @Timestamp in filter for better performance. + /// 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. + /// Optional timeout in seconds (1-300). If not specified, uses the default cancellation token. /// Optional workspace identifier for multi-tenant scenarios /// Cancellation token /// List of matching events - [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> 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) { @@ -34,21 +55,87 @@ public static async Task> SeqSearch( { var conn = fac.Create(workspace); var events = new List(); - 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 diff --git a/tests/SeqMcpServer.Tests/McpToolsIntegrationTests.cs b/tests/SeqMcpServer.Tests/McpToolsIntegrationTests.cs index 613f25e..e427cb3 100644 --- a/tests/SeqMcpServer.Tests/McpToolsIntegrationTests.cs +++ b/tests/SeqMcpServer.Tests/McpToolsIntegrationTests.cs @@ -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 + { + ["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 + { + ["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 + { + ["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 + { + ["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 + { + ["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 + { + ["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 + { + ["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 + { + ["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 + { + ["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"); + } } \ No newline at end of file