From acb34731678d625bac70e893f0ad326cb4c3b4a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 10:00:58 +0000 Subject: [PATCH 1/7] Initial plan From 04641b2f4c9968d79abebad46d2f7eaf5edd1428 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 10:08:58 +0000 Subject: [PATCH 2/7] Add FindTrainConnections MCP tool with tests Co-authored-by: abeckDev <8720854+abeckDev@users.noreply.github.com> --- .../TimeTableServiceTests.cs | 109 ++++++ .../TimetableToolsTests.cs | 125 +++++++ .../Services/ITimeTableService.cs | 5 + .../Services/TimeTableService.cs | 342 ++++++++++++++++++ AbeckDev.DbTimetable.Mcp/Tools.cs | 35 ++ 5 files changed, 616 insertions(+) diff --git a/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs b/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs index 6bcd616..86d7021 100644 --- a/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs +++ b/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs @@ -360,4 +360,113 @@ public async Task GetStationInformation_SetsCorrectHeaders() // Assert VerifyHttpRequest(mockHandler, $"station/{pattern}", _config.ClientId, _config.ApiKey); } + + [Fact] + public async Task FindTrainConnectionsAsync_WithValidStations_ReturnsAnalysisReport() + { + // Arrange + var stationXml = @" + + + "; + + var timetableXml = @" + + + + + + "; + + var changesXml = @""; + + var mockHandler = new Mock(); + var requestCount = 0; + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + requestCount++; + // First two calls are station lookups, third is timetable, fourth is changes + if (requestCount <= 2) + return new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(stationXml) }; + if (requestCount == 3) + return new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(timetableXml) }; + return new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(changesXml) }; + }); + + var httpClient = new HttpClient(mockHandler.Object) { BaseAddress = new Uri(_config.BaseUrl) }; + var service = new TimeTableService(httpClient, _mockOptions.Object); + + // Act + var result = await service.FindTrainConnectionsAsync("Frankfurt", "Berlin"); + + // Assert + Assert.Contains("Train Connection Analysis", result); + Assert.Contains("Frankfurt Hbf", result); + Assert.Contains("EVA: 8000105", result); + } + + [Fact] + public async Task FindTrainConnectionsAsync_WithInvalidStationA_ReturnsError() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.NotFound, + Content = new StringContent("Not Found") + }); + + var httpClient = new HttpClient(mockHandler.Object) { BaseAddress = new Uri(_config.BaseUrl) }; + var service = new TimeTableService(httpClient, _mockOptions.Object); + + // Act + var result = await service.FindTrainConnectionsAsync("InvalidStation", "Berlin"); + + // Assert + Assert.Contains("Could not find station 'InvalidStation'", result); + } + + [Fact] + public async Task FindTrainConnectionsAsync_WithInvalidStationB_ReturnsError() + { + // Arrange + var stationAXml = @" + + + "; + + var mockHandler = new Mock(); + var requestCount = 0; + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + requestCount++; + if (requestCount == 1) + return new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(stationAXml) }; + return new HttpResponseMessage { StatusCode = HttpStatusCode.NotFound, Content = new StringContent("Not Found") }; + }); + + var httpClient = new HttpClient(mockHandler.Object) { BaseAddress = new Uri(_config.BaseUrl) }; + var service = new TimeTableService(httpClient, _mockOptions.Object); + + // Act + var result = await service.FindTrainConnectionsAsync("Frankfurt", "InvalidStation"); + + // Assert + Assert.Contains("Could not find station 'InvalidStation'", result); + } } diff --git a/AbeckDev.DbTimetable.Mcp.Test/TimetableToolsTests.cs b/AbeckDev.DbTimetable.Mcp.Test/TimetableToolsTests.cs index 9bf555e..619156e 100644 --- a/AbeckDev.DbTimetable.Mcp.Test/TimetableToolsTests.cs +++ b/AbeckDev.DbTimetable.Mcp.Test/TimetableToolsTests.cs @@ -292,4 +292,129 @@ public async Task GetStationDetails_WithGeneralException_ReturnsUnexpectedErrorM Assert.Contains("Unexpected error:", result); Assert.Contains(exceptionMessage, result); } + + [Fact] + public async Task FindTrainConnections_WithValidStations_ReturnsConnectionAnalysis() + { + // Arrange + var stationA = "Frankfurt"; + var stationB = "Berlin"; + var expectedResult = "=== Train Connection Analysis ===\nConnections found"; + + _mockService.Setup(s => s.FindTrainConnectionsAsync(stationA, stationB, null, default)) + .ReturnsAsync(expectedResult); + + var tools = new Tools.TimetableTools(_mockService.Object); + + // Act + var result = await tools.FindTrainConnections(stationA, stationB, null); + + // Assert + Assert.Equal(expectedResult, result); + _mockService.Verify(s => s.FindTrainConnectionsAsync(stationA, stationB, null, default), Times.Once); + } + + [Fact] + public async Task FindTrainConnections_WithValidDateTime_ParsesAndCallsService() + { + // Arrange + var stationA = "Frankfurt"; + var stationB = "Berlin"; + var dateTimeString = "2025-11-06 14:30"; + var parsedDateTime = DateTime.Parse(dateTimeString); + var expectedResult = "Connections found"; + + _mockService.Setup(s => s.FindTrainConnectionsAsync(stationA, stationB, parsedDateTime, default)) + .ReturnsAsync(expectedResult); + + var tools = new Tools.TimetableTools(_mockService.Object); + + // Act + var result = await tools.FindTrainConnections(stationA, stationB, dateTimeString); + + // Assert + Assert.Equal(expectedResult, result); + _mockService.Verify(s => s.FindTrainConnectionsAsync(stationA, stationB, parsedDateTime, default), Times.Once); + } + + [Fact] + public async Task FindTrainConnections_WithInvalidDateTimeFormat_ReturnsErrorMessage() + { + // Arrange + var stationA = "Frankfurt"; + var stationB = "Berlin"; + var invalidDateTime = "invalid-date-format"; + + var tools = new Tools.TimetableTools(_mockService.Object); + + // Act + var result = await tools.FindTrainConnections(stationA, stationB, invalidDateTime); + + // Assert + Assert.Contains("Error: Invalid date format", result); + Assert.Contains("yyyy-MM-dd HH:mm", result); + } + + [Fact] + public async Task FindTrainConnections_WithEmptyDateTime_CallsServiceWithNull() + { + // Arrange + var stationA = "Frankfurt"; + var stationB = "Berlin"; + var expectedResult = "Connections found"; + + _mockService.Setup(s => s.FindTrainConnectionsAsync(stationA, stationB, null, default)) + .ReturnsAsync(expectedResult); + + var tools = new Tools.TimetableTools(_mockService.Object); + + // Act + var result = await tools.FindTrainConnections(stationA, stationB, ""); + + // Assert + Assert.Equal(expectedResult, result); + _mockService.Verify(s => s.FindTrainConnectionsAsync(stationA, stationB, null, default), Times.Once); + } + + [Fact] + public async Task FindTrainConnections_WithHttpRequestException_ReturnsErrorMessage() + { + // Arrange + var stationA = "Frankfurt"; + var stationB = "Berlin"; + var exceptionMessage = "Network error"; + + _mockService.Setup(s => s.FindTrainConnectionsAsync(stationA, stationB, null, default)) + .ThrowsAsync(new HttpRequestException(exceptionMessage)); + + var tools = new Tools.TimetableTools(_mockService.Object); + + // Act + var result = await tools.FindTrainConnections(stationA, stationB, null); + + // Assert + Assert.Contains("Error finding train connections:", result); + Assert.Contains(exceptionMessage, result); + } + + [Fact] + public async Task FindTrainConnections_WithGeneralException_ReturnsUnexpectedErrorMessage() + { + // Arrange + var stationA = "Frankfurt"; + var stationB = "Berlin"; + var exceptionMessage = "Unexpected error"; + + _mockService.Setup(s => s.FindTrainConnectionsAsync(stationA, stationB, null, default)) + .ThrowsAsync(new InvalidOperationException(exceptionMessage)); + + var tools = new Tools.TimetableTools(_mockService.Object); + + // Act + var result = await tools.FindTrainConnections(stationA, stationB, null); + + // Assert + Assert.Contains("Unexpected error:", result); + Assert.Contains(exceptionMessage, result); + } } diff --git a/AbeckDev.DbTimetable.Mcp/Services/ITimeTableService.cs b/AbeckDev.DbTimetable.Mcp/Services/ITimeTableService.cs index 67b0b5d..0ddc77a 100644 --- a/AbeckDev.DbTimetable.Mcp/Services/ITimeTableService.cs +++ b/AbeckDev.DbTimetable.Mcp/Services/ITimeTableService.cs @@ -21,4 +21,9 @@ public interface ITimeTableService /// Get information about stations given either a station name (prefix), eva number, ds100/rl100 code, wildcard (*); doesn't seem to work with umlauten in station name (prefix) /// Task GetStationInformation(string pattern, CancellationToken cancellationToken = default); + + /// + /// Find train connections between two stations and assess their current status including delays and disruptions + /// + Task FindTrainConnectionsAsync(string stationA, string stationB, DateTime? dateTime = null, CancellationToken cancellationToken = default); } diff --git a/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs b/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs index 1b395d0..377d17b 100644 --- a/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs +++ b/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs @@ -1,4 +1,7 @@ using System; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; using AbeckDev.DbTimetable.Mcp.Models; using Microsoft.Extensions.Options; @@ -96,4 +99,343 @@ public async Task GetStationInformation( return await response.Content.ReadAsStringAsync(cancellationToken); } + /// + /// Find train connections between two stations and assess their current status including delays and disruptions. + /// This method orchestrates multiple API calls to: + /// 1. Resolve station names to EVA IDs + /// 2. Get timetables for both stations + /// 3. Find trains that stop at both stations + /// 4. Check for delays and disruptions + /// 5. Return ranked connection options + /// + public async Task FindTrainConnectionsAsync( + string stationA, + string stationB, + DateTime? dateTime = null, + CancellationToken cancellationToken = default) + { + var result = new StringBuilder(); + result.AppendLine("=== Train Connection Analysis ==="); + result.AppendLine(); + + try + { + // Step 1: Resolve station A + result.AppendLine($"Step 1: Resolving station '{stationA}'..."); + var stationAInfo = await ResolveStationAsync(stationA, cancellationToken); + if (stationAInfo == null) + { + return $"Error: Could not find station '{stationA}'. Please check the station name."; + } + result.AppendLine($" ✓ Found: {stationAInfo.Name} (EVA: {stationAInfo.EvaNo})"); + result.AppendLine(); + + // Step 2: Resolve station B + result.AppendLine($"Step 2: Resolving station '{stationB}'..."); + var stationBInfo = await ResolveStationAsync(stationB, cancellationToken); + if (stationBInfo == null) + { + return $"Error: Could not find station '{stationB}'. Please check the station name."; + } + result.AppendLine($" ✓ Found: {stationBInfo.Name} (EVA: {stationBInfo.EvaNo})"); + result.AppendLine(); + + // Step 3: Get timetable for station A + result.AppendLine($"Step 3: Fetching departures from {stationAInfo.Name}..."); + var timetableA = await GetStationBoardAsync(stationAInfo.EvaNo, dateTime, cancellationToken); + result.AppendLine(" ✓ Timetable retrieved"); + result.AppendLine(); + + // Step 4: Get changes for station A to check for delays/disruptions + result.AppendLine($"Step 4: Checking for delays and disruptions at {stationAInfo.Name}..."); + string changesA; + try + { + changesA = await GetRecentTimetableChangesAsync(stationAInfo.EvaNo, cancellationToken); + result.AppendLine(" ✓ Recent changes retrieved"); + } + catch + { + changesA = string.Empty; + result.AppendLine(" ⚠ No recent changes available"); + } + result.AppendLine(); + + // Step 5: Analyze connections + result.AppendLine($"Step 5: Finding trains from {stationAInfo.Name} to {stationBInfo.Name}..."); + var connections = FindConnectionsInTimetable(timetableA, changesA, stationAInfo, stationBInfo); + + if (connections.Count == 0) + { + result.AppendLine(" ⚠ No direct connections found in the current timetable."); + result.AppendLine(); + result.AppendLine("This could mean:"); + result.AppendLine("- No direct trains operate between these stations"); + result.AppendLine("- Trains may require a transfer"); + result.AppendLine("- Try a different time or date"); + } + else + { + result.AppendLine($" ✓ Found {connections.Count} connection(s)"); + result.AppendLine(); + result.AppendLine("=== Available Connections ==="); + result.AppendLine(); + + int rank = 1; + foreach (var conn in connections.OrderBy(c => c.TotalDelay).ThenBy(c => c.DepartureTime)) + { + result.AppendLine($"Option {rank}: {conn.TrainType} {conn.TrainNumber}"); + result.AppendLine($" Departure: {conn.DepartureTime:HH:mm} from {stationAInfo.Name}"); + result.AppendLine($" Platform: {conn.DeparturePlatform ?? "TBA"}"); + + if (conn.ScheduledDepartureTime.HasValue && conn.DepartureTime != conn.ScheduledDepartureTime) + { + result.AppendLine($" ⚠ Originally scheduled: {conn.ScheduledDepartureTime:HH:mm}"); + } + + if (conn.TotalDelay > 0) + { + result.AppendLine($" ⚠ Delay: +{conn.TotalDelay} minutes"); + } + else + { + result.AppendLine($" ✓ On time"); + } + + if (!string.IsNullOrEmpty(conn.Messages)) + { + result.AppendLine($" Messages: {conn.Messages}"); + } + + if (conn.IsCancelled) + { + result.AppendLine($" ❌ CANCELLED"); + } + + result.AppendLine($" Destination: {conn.FinalDestination}"); + result.AppendLine(); + rank++; + } + + // Recommendation + result.AppendLine("=== Recommendation ==="); + var bestConnection = connections.OrderBy(c => c.IsCancelled ? 1 : 0) + .ThenBy(c => c.TotalDelay) + .ThenBy(c => c.DepartureTime) + .First(); + + if (bestConnection.IsCancelled) + { + result.AppendLine("⚠ The earliest connection is cancelled. Check alternative options above."); + } + else if (bestConnection.TotalDelay == 0) + { + result.AppendLine($"✓ Best option: {bestConnection.TrainType} {bestConnection.TrainNumber} at {bestConnection.DepartureTime:HH:mm} - On time"); + } + else + { + result.AppendLine($"⚠ Best option: {bestConnection.TrainType} {bestConnection.TrainNumber} at {bestConnection.DepartureTime:HH:mm} - Delayed by {bestConnection.TotalDelay} minutes"); + } + } + + return result.ToString(); + } + catch (Exception ex) + { + result.AppendLine(); + result.AppendLine($"Error during connection analysis: {ex.Message}"); + return result.ToString(); + } + } + + /// + /// Helper to resolve a station name or EVA number to station information + /// + private async Task ResolveStationAsync(string pattern, CancellationToken cancellationToken) + { + try + { + var stationXml = await GetStationInformation(pattern, cancellationToken); + return ParseFirstStation(stationXml); + } + catch + { + return null; + } + } + + /// + /// Parse the first station from the station information XML + /// + private StationInfo? ParseFirstStation(string xml) + { + try + { + var doc = XDocument.Parse(xml); + var station = doc.Descendants("station").FirstOrDefault(); + if (station == null) return null; + + return new StationInfo + { + Name = station.Attribute("name")?.Value ?? "", + EvaNo = station.Attribute("eva")?.Value ?? "" + }; + } + catch + { + return null; + } + } + + /// + /// Find connections in the timetable that potentially go through both stations + /// + private List FindConnectionsInTimetable(string timetableXml, string changesXml, StationInfo stationA, StationInfo stationB) + { + var connections = new List(); + + try + { + var timetableDoc = XDocument.Parse(timetableXml); + XDocument? changesDoc = null; + try + { + if (!string.IsNullOrEmpty(changesXml)) + { + changesDoc = XDocument.Parse(changesXml); + } + } + catch { } + + // Parse all train events (stops) from the timetable + var stops = timetableDoc.Descendants("s").ToList(); + + foreach (var stop in stops) + { + // Check if this train goes to the destination by looking at the path (ppth) or final destination + var path = stop.Attribute("ppth")?.Value ?? ""; + var destination = stop.Element("tl")?.Attribute("f")?.Value ?? + stop.Element("dp")?.Attribute("ppth")?.Value?.Split('|').LastOrDefault() ?? ""; + + // Check if the destination station or path contains our target station + // This is a heuristic - in reality we'd need to check the full route + var pathStations = path.Split('|').Select(s => s.Trim()).ToList(); + var goesToDestination = pathStations.Any(ps => + ps.Equals(stationB.Name, StringComparison.OrdinalIgnoreCase) || + ps.Contains(stationB.Name.Split(' ')[0], StringComparison.OrdinalIgnoreCase)); + + if (!goesToDestination && !destination.Contains(stationB.Name.Split(' ')[0], StringComparison.OrdinalIgnoreCase)) + { + continue; // Skip trains that don't go to our destination + } + + var trainId = stop.Attribute("id")?.Value ?? ""; + var trainElement = stop.Element("tl"); + var trainType = trainElement?.Attribute("c")?.Value ?? ""; + var trainNumber = trainElement?.Attribute("n")?.Value ?? ""; + + var departureElement = stop.Element("dp"); + if (departureElement == null) continue; // Only interested in departures + + var scheduledDeparture = departureElement.Attribute("pt")?.Value; + var actualDeparture = departureElement.Attribute("ct")?.Value ?? scheduledDeparture; + var platform = departureElement.Attribute("pp")?.Value; + var changedPlatform = departureElement.Attribute("cp")?.Value; + + if (string.IsNullOrEmpty(actualDeparture)) continue; + + // Parse departure times + var departureTime = ParseTimetableDateTime(actualDeparture); + var scheduledDepartureTime = ParseTimetableDateTime(scheduledDeparture); + + // Calculate delay + int delay = 0; + if (scheduledDepartureTime.HasValue && departureTime.HasValue) + { + delay = (int)(departureTime.Value - scheduledDepartureTime.Value).TotalMinutes; + } + + // Check for cancellation + var isCancelled = departureElement.Attribute("cs")?.Value == "c"; + + // Get messages + var messages = string.Join("; ", stop.Elements("m") + .Select(m => m.Attribute("t")?.Value) + .Where(m => !string.IsNullOrEmpty(m))); + + connections.Add(new ConnectionInfo + { + TrainId = trainId, + TrainType = trainType, + TrainNumber = trainNumber, + DepartureTime = departureTime ?? DateTime.MinValue, + ScheduledDepartureTime = scheduledDepartureTime, + DeparturePlatform = changedPlatform ?? platform, + TotalDelay = delay, + IsCancelled = isCancelled, + Messages = messages, + FinalDestination = destination + }); + } + } + catch + { + // If parsing fails, return empty list + } + + return connections; + } + + /// + /// Parse Deutsche Bahn timetable date format (YYMMddHHmm) + /// + private DateTime? ParseTimetableDateTime(string? dateTimeStr) + { + if (string.IsNullOrEmpty(dateTimeStr)) return null; + + try + { + // Format: YYMMddHHmm (e.g., "2511061430" for 2025-11-06 14:30) + if (dateTimeStr.Length == 10) + { + var year = 2000 + int.Parse(dateTimeStr.Substring(0, 2)); + var month = int.Parse(dateTimeStr.Substring(2, 2)); + var day = int.Parse(dateTimeStr.Substring(4, 2)); + var hour = int.Parse(dateTimeStr.Substring(6, 2)); + var minute = int.Parse(dateTimeStr.Substring(8, 2)); + + return new DateTime(year, month, day, hour, minute, 0); + } + } + catch { } + + return null; + } + + /// + /// Station information + /// + private class StationInfo + { + public string Name { get; set; } = ""; + public string EvaNo { get; set; } = ""; + } + + /// + /// Connection information + /// + private class ConnectionInfo + { + public string TrainId { get; set; } = ""; + public string TrainType { get; set; } = ""; + public string TrainNumber { get; set; } = ""; + public DateTime DepartureTime { get; set; } + public DateTime? ScheduledDepartureTime { get; set; } + public string? DeparturePlatform { get; set; } + public int TotalDelay { get; set; } + public bool IsCancelled { get; set; } + public string Messages { get; set; } = ""; + public string FinalDestination { get; set; } = ""; + } + } diff --git a/AbeckDev.DbTimetable.Mcp/Tools.cs b/AbeckDev.DbTimetable.Mcp/Tools.cs index 5cd66d3..3ecd04c 100644 --- a/AbeckDev.DbTimetable.Mcp/Tools.cs +++ b/AbeckDev.DbTimetable.Mcp/Tools.cs @@ -113,5 +113,40 @@ public async Task GetStationBoard( return $"Unexpected error: {ex.Message}"; } } + + [McpServerTool] + [Description("Find and assess train connections between two stations. Takes station names or EVA numbers as input, validates them, finds all trains operating between the stations, checks for current delays and disruptions, and provides ranked connection options with recommendations.")] + public async Task FindTrainConnections( + [Description("Starting station name or EVA number (e.g., 'Frankfurt Hbf' or '8000105')")] string stationA, + [Description("Destination station name or EVA number (e.g., 'Berlin Hbf' or '8011160')")] string stationB, + [Description("Date and time in format 'yyyy-MM-dd HH:mm' (UTC). Leave empty for current time.")] string? dateTime = null) + { + try + { + DateTime? parsedDate = null; + if (!string.IsNullOrEmpty(dateTime)) + { + if (DateTime.TryParse(dateTime, out var dt)) + { + parsedDate = dt; + } + else + { + return "Error: Invalid date format. Please use 'yyyy-MM-dd HH:mm' format."; + } + } + + var result = await _timeTableService.FindTrainConnectionsAsync(stationA, stationB, parsedDate); + return result; + } + catch (HttpRequestException ex) + { + return $"Error finding train connections: {ex.Message}"; + } + catch (Exception ex) + { + return $"Unexpected error: {ex.Message}"; + } + } } } From d6da128b5e82bf427da3d4e982198d86d5273cb7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 10:10:27 +0000 Subject: [PATCH 3/7] Update README with FindTrainConnections documentation Co-authored-by: abeckDev <8720854+abeckDev@users.noreply.github.com> --- README.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0374d6c..188d863 100644 --- a/README.md +++ b/README.md @@ -438,7 +438,7 @@ Once the server is running, you can test it using the MCP Inspector: 1. **List Available Tools**: - In the MCP Inspector, click "List Tools" to see all available MCP tools - - You should see: `GetStationBoard`, `GetStationChanges`, `GetFullTimetableChanges`, `GetStationDetails` + - You should see: `GetStationBoard`, `GetStationChanges`, `GetFullTimetableChanges`, `GetStationDetails`, `FindTrainConnections` 2. **Test GetStationDetails**: - Select the `GetStationDetails` tool @@ -463,6 +463,7 @@ To integrate this MCP server with AI agents or client applications: - `GetStationChanges`: Get recent changes (delays, cancellations) - `GetFullTimetableChanges`: Get all timetable changes for an event - `GetStationDetails`: Search for station information + - `FindTrainConnections`: Find and assess train connections between two stations with delay information For production deployments, consider: - Using HTTPS with proper SSL certificates @@ -666,6 +667,72 @@ The server exposes the following MCP tools for AI agents to interact with Deutsc } ``` +### 5. FindTrainConnections + +**Description**: Find and assess train connections between two stations. This comprehensive tool validates station names, finds all trains operating between the stations, checks for current delays and disruptions, and provides ranked connection options with recommendations. + +**Parameters**: +- `stationA` (required): Starting station name or EVA number (e.g., `"Frankfurt Hbf"` or `"8000105"`) +- `stationB` (required): Destination station name or EVA number (e.g., `"Berlin Hbf"` or `"8011160"`) +- `dateTime` (optional): Date and time in format `yyyy-MM-dd HH:mm` (UTC). Leave empty for current time. + +**Returns**: A comprehensive analysis report including: +- Validated station names with EVA numbers +- List of available train connections +- Departure times (scheduled and actual) +- Platform information +- Current delays and delay duration +- Cancellation status +- Service messages and disruptions +- Recommendation for best connection + +**Example**: +```json +{ + "stationA": "Frankfurt Hbf", + "stationB": "Berlin Hbf", + "dateTime": "2025-11-06 14:30" +} +``` + +**Sample Output**: +``` +=== Train Connection Analysis === + +Step 1: Resolving station 'Frankfurt Hbf'... + ✓ Found: Frankfurt(Main)Hbf (EVA: 8000105) + +Step 2: Resolving station 'Berlin Hbf'... + ✓ Found: Berlin Hbf (EVA: 8011160) + +Step 3: Fetching departures from Frankfurt(Main)Hbf... + ✓ Timetable retrieved + +Step 4: Checking for delays and disruptions at Frankfurt(Main)Hbf... + ✓ Recent changes retrieved + +Step 5: Finding trains from Frankfurt(Main)Hbf to Berlin Hbf... + ✓ Found 3 connection(s) + +=== Available Connections === + +Option 1: ICE 1234 + Departure: 14:30 from Frankfurt(Main)Hbf + Platform: 7 + ✓ On time + Destination: Berlin Hbf + +Option 2: ICE 5678 + Departure: 15:30 from Frankfurt(Main)Hbf + Platform: 9 + ⚠ Originally scheduled: 15:25 + ⚠ Delay: +5 minutes + Destination: Berlin Hbf + +=== Recommendation === +✓ Best option: ICE 1234 at 14:30 - On time +``` + ### Common EVA Station Numbers Here are some commonly used EVA station numbers for testing: From f411fa03325455fcd4dfabc53f0c0c95cb625d4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 10:12:40 +0000 Subject: [PATCH 4/7] Address code review feedback - add null safety and improve documentation Co-authored-by: abeckDev <8720854+abeckDev@users.noreply.github.com> --- .../Services/TimeTableService.cs | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs b/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs index 377d17b..831d249 100644 --- a/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs +++ b/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs @@ -318,13 +318,17 @@ private List FindConnectionsInTimetable(string timetableXml, str stop.Element("dp")?.Attribute("ppth")?.Value?.Split('|').LastOrDefault() ?? ""; // Check if the destination station or path contains our target station - // This is a heuristic - in reality we'd need to check the full route + // This is a heuristic - the actual route might not be fully represented + // We use the first word of the station name for matching to handle complex station names var pathStations = path.Split('|').Select(s => s.Trim()).ToList(); + var stationBFirstWord = GetFirstWord(stationB.Name); + var goesToDestination = pathStations.Any(ps => ps.Equals(stationB.Name, StringComparison.OrdinalIgnoreCase) || - ps.Contains(stationB.Name.Split(' ')[0], StringComparison.OrdinalIgnoreCase)); + (!string.IsNullOrEmpty(stationBFirstWord) && ps.Contains(stationBFirstWord, StringComparison.OrdinalIgnoreCase))); - if (!goesToDestination && !destination.Contains(stationB.Name.Split(' ')[0], StringComparison.OrdinalIgnoreCase)) + if (!goesToDestination && + (string.IsNullOrEmpty(stationBFirstWord) || !destination.Contains(stationBFirstWord, StringComparison.OrdinalIgnoreCase))) { continue; // Skip trains that don't go to our destination } @@ -388,6 +392,7 @@ private List FindConnectionsInTimetable(string timetableXml, str /// /// Parse Deutsche Bahn timetable date format (YYMMddHHmm) + /// Example: "2511061430" represents 2025-11-06 14:30 (YY=25, MM=11, dd=06, HH=14, mm=30) /// private DateTime? ParseTimetableDateTime(string? dateTimeStr) { @@ -412,6 +417,19 @@ private List FindConnectionsInTimetable(string timetableXml, str return null; } + /// + /// Helper method to safely get the first word from a station name + /// Returns empty string if the input is null or empty + /// + private string GetFirstWord(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return ""; + + var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return words.Length > 0 ? words[0] : ""; + } + /// /// Station information /// From 4f884e62dedf68ff2f72b2a5fe10156fb2937606 Mon Sep 17 00:00:00 2001 From: Alexander Beck Date: Thu, 6 Nov 2025 10:46:18 +0000 Subject: [PATCH 5/7] Fixed path mapping issue The path variable at line 323 (originally line 316) was always empty because: Root Cause: The code was trying to read the ppth attribute from the element (stop.Attribute("ppth")), but according to the Deutsche Bahn Timetable API XML schema, the ppth (path) attribute is actually located on the (departure) or (arrival) child elements, not on the parent (stop) element. The Fix: Moved the departureElement declaration earlier in the loop Read the ppth attribute from departureElement instead of stop Removed the duplicate departureElement declaration later in the code Now the code correctly retrieves the path information from and should be able to find train connections that go through both stations. --- AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs b/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs index 831d249..e04e717 100644 --- a/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs +++ b/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs @@ -313,9 +313,11 @@ private List FindConnectionsInTimetable(string timetableXml, str foreach (var stop in stops) { // Check if this train goes to the destination by looking at the path (ppth) or final destination - var path = stop.Attribute("ppth")?.Value ?? ""; + // The ppth attribute is on the dp (departure) element, not on the s (stop) element + var departureElement = stop.Element("dp"); + var path = departureElement?.Attribute("ppth")?.Value ?? ""; var destination = stop.Element("tl")?.Attribute("f")?.Value ?? - stop.Element("dp")?.Attribute("ppth")?.Value?.Split('|').LastOrDefault() ?? ""; + path.Split('|').LastOrDefault() ?? ""; // Check if the destination station or path contains our target station // This is a heuristic - the actual route might not be fully represented @@ -338,7 +340,6 @@ private List FindConnectionsInTimetable(string timetableXml, str var trainType = trainElement?.Attribute("c")?.Value ?? ""; var trainNumber = trainElement?.Attribute("n")?.Value ?? ""; - var departureElement = stop.Element("dp"); if (departureElement == null) continue; // Only interested in departures var scheduledDeparture = departureElement.Attribute("pt")?.Value; From adc27250f3a96efd726f46441d844f1c860d9b1c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 10:56:49 +0000 Subject: [PATCH 6/7] Use GetFullChangesAsync for live delay data and extract changes per train - Changed from GetRecentTimetableChangesAsync to GetFullChangesAsync to get all changes instead of just last 2 minutes - Implemented proper matching of changes data to each specific train by train ID - Extract live delay, cancellation status, platform changes, and messages from the full changes data - Added test case to verify live delay information is properly extracted and displayed Co-authored-by: abeckDev <8720854+abeckDev@users.noreply.github.com> --- .../TimeTableServiceTests.cs | 60 +++++++++++++- .../Services/TimeTableService.cs | 79 +++++++++++++++---- 2 files changed, 124 insertions(+), 15 deletions(-) diff --git a/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs b/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs index 86d7021..f82c059 100644 --- a/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs +++ b/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs @@ -390,7 +390,7 @@ public async Task FindTrainConnectionsAsync_WithValidStations_ReturnsAnalysisRep .ReturnsAsync(() => { requestCount++; - // First two calls are station lookups, third is timetable, fourth is changes + // First two calls are station lookups, third is timetable, fourth is full changes if (requestCount <= 2) return new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(stationXml) }; if (requestCount == 3) @@ -410,6 +410,64 @@ public async Task FindTrainConnectionsAsync_WithValidStations_ReturnsAnalysisRep Assert.Contains("EVA: 8000105", result); } + [Fact] + public async Task FindTrainConnectionsAsync_WithLiveDelayData_ReturnsDelayInformation() + { + // Arrange + var stationXml = @" + + + "; + + var timetableXml = @" + + + + + + "; + + // Changes XML with live delay data - train delayed by 10 minutes + var changesXml = @" + + + + + + + "; + + var mockHandler = new Mock(); + var requestCount = 0; + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + requestCount++; + // First two calls are station lookups, third is timetable, fourth is full changes + if (requestCount <= 2) + return new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(stationXml) }; + if (requestCount == 3) + return new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(timetableXml) }; + return new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(changesXml) }; + }); + + var httpClient = new HttpClient(mockHandler.Object) { BaseAddress = new Uri(_config.BaseUrl) }; + var service = new TimeTableService(httpClient, _mockOptions.Object); + + // Act + var result = await service.FindTrainConnectionsAsync("Frankfurt", "Berlin"); + + // Assert + Assert.Contains("Train Connection Analysis", result); + Assert.Contains("Delay: +10 minutes", result); + Assert.Contains("Train delayed due to technical issues", result); + Assert.Contains("Platform: 8", result); // Changed platform + } + [Fact] public async Task FindTrainConnectionsAsync_WithInvalidStationA_ReturnsError() { diff --git a/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs b/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs index e04e717..4de5189 100644 --- a/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs +++ b/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs @@ -146,18 +146,18 @@ public async Task FindTrainConnectionsAsync( result.AppendLine(" ✓ Timetable retrieved"); result.AppendLine(); - // Step 4: Get changes for station A to check for delays/disruptions + // Step 4: Get full changes for station A to check for delays/disruptions result.AppendLine($"Step 4: Checking for delays and disruptions at {stationAInfo.Name}..."); string changesA; try { - changesA = await GetRecentTimetableChangesAsync(stationAInfo.EvaNo, cancellationToken); - result.AppendLine(" ✓ Recent changes retrieved"); + changesA = await GetFullChangesAsync(stationAInfo.EvaNo, cancellationToken); + result.AppendLine(" ✓ Full changes retrieved"); } catch { changesA = string.Empty; - result.AppendLine(" ⚠ No recent changes available"); + result.AppendLine(" ⚠ No changes available"); } result.AppendLine(); @@ -353,21 +353,72 @@ private List FindConnectionsInTimetable(string timetableXml, str var departureTime = ParseTimetableDateTime(actualDeparture); var scheduledDepartureTime = ParseTimetableDateTime(scheduledDeparture); - // Calculate delay + // Look up live changes for this specific train from the full changes data int delay = 0; - if (scheduledDepartureTime.HasValue && departureTime.HasValue) + bool isCancelled = departureElement.Attribute("cs")?.Value == "c"; + string messages = string.Join("; ", stop.Elements("m") + .Select(m => m.Attribute("t")?.Value) + .Where(m => !string.IsNullOrEmpty(m))); + + // If we have changes data, look for this specific train and extract live information + if (changesDoc != null) + { + var changeStop = changesDoc.Descendants("s") + .FirstOrDefault(s => s.Attribute("id")?.Value == trainId); + + if (changeStop != null) + { + var changeDp = changeStop.Element("dp"); + if (changeDp != null) + { + // Get live departure time from changes + var liveActualDeparture = changeDp.Attribute("ct")?.Value; + var liveScheduledDeparture = changeDp.Attribute("pt")?.Value; + + if (!string.IsNullOrEmpty(liveActualDeparture) && !string.IsNullOrEmpty(liveScheduledDeparture)) + { + var liveDepartureTime = ParseTimetableDateTime(liveActualDeparture); + var liveScheduledTime = ParseTimetableDateTime(liveScheduledDeparture); + + if (liveDepartureTime.HasValue && liveScheduledTime.HasValue) + { + delay = (int)(liveDepartureTime.Value - liveScheduledTime.Value).TotalMinutes; + departureTime = liveDepartureTime; // Use live time + } + } + + // Check for cancellation status in changes + var liveStatus = changeDp.Attribute("cs")?.Value; + if (liveStatus == "c") + { + isCancelled = true; + } + + // Get changed platform if available + var liveChangedPlatform = changeDp.Attribute("cp")?.Value; + if (!string.IsNullOrEmpty(liveChangedPlatform)) + { + changedPlatform = liveChangedPlatform; + } + } + + // Get messages from changes (these are more up-to-date) + var changeMessages = string.Join("; ", changeStop.Elements("m") + .Select(m => m.Attribute("t")?.Value) + .Where(m => !string.IsNullOrEmpty(m))); + + if (!string.IsNullOrEmpty(changeMessages)) + { + messages = changeMessages; + } + } + } + // Fallback: if no changes data, calculate delay from timetable data + else if (scheduledDepartureTime.HasValue && departureTime.HasValue) { delay = (int)(departureTime.Value - scheduledDepartureTime.Value).TotalMinutes; } - // Check for cancellation - var isCancelled = departureElement.Attribute("cs")?.Value == "c"; - - // Get messages - var messages = string.Join("; ", stop.Elements("m") - .Select(m => m.Attribute("t")?.Value) - .Where(m => !string.IsNullOrEmpty(m))); - connections.Add(new ConnectionInfo { TrainId = trainId, From ed1a02c1e89fe0bb496ae7a661b5c1e2ee65e627 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:20:59 +0000 Subject: [PATCH 7/7] Add tests for no changes available and no connections found scenarios - Added test for when GetFullChangesAsync fails (no changes available case) - Added test for when no train connections match the destination station - Coverage increased from previous level to 86.87% line coverage (well above 73% threshold) - All 42 tests passing Co-authored-by: abeckDev <8720854+abeckDev@users.noreply.github.com> --- .../TimeTableServiceTests.cs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs b/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs index f82c059..f9de8d6 100644 --- a/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs +++ b/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs @@ -527,4 +527,115 @@ public async Task FindTrainConnectionsAsync_WithInvalidStationB_ReturnsError() // Assert Assert.Contains("Could not find station 'InvalidStation'", result); } + + [Fact] + public async Task FindTrainConnectionsAsync_WhenNoChangesAvailable_ReturnsWarning() + { + // Arrange + var stationXml = @" + + + "; + + var timetableXml = @" + + + + + + "; + + var mockHandler = new Mock(); + var requestCount = 0; + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + requestCount++; + // First two calls are station lookups + if (requestCount <= 2) + return new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(stationXml) }; + // Third is timetable + if (requestCount == 3) + return new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(timetableXml) }; + // Fourth is full changes - simulate failure + return new HttpResponseMessage { StatusCode = HttpStatusCode.InternalServerError, Content = new StringContent("Server Error") }; + }); + + var httpClient = new HttpClient(mockHandler.Object) { BaseAddress = new Uri(_config.BaseUrl) }; + var service = new TimeTableService(httpClient, _mockOptions.Object); + + // Act + var result = await service.FindTrainConnectionsAsync("Frankfurt", "Berlin"); + + // Assert + Assert.Contains("Train Connection Analysis", result); + Assert.Contains("⚠ No changes available", result); + } + + [Fact] + public async Task FindTrainConnectionsAsync_WhenNoConnectionsFound_ReturnsNoConnectionsMessage() + { + // Arrange + var stationXml = @" + + + "; + + var stationBXml = @" + + + "; + + // Timetable with a train that doesn't go to Konstanz (only to Munich) + var timetableXml = @" + + + + + + "; + + var changesXml = @""; + + var mockHandler = new Mock(); + var requestCount = 0; + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + requestCount++; + // First station lookup (Frankfurt) + if (requestCount == 1) + return new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(stationXml) }; + // Second station lookup (Konstanz) + if (requestCount == 2) + return new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(stationBXml) }; + // Third is timetable + if (requestCount == 3) + return new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(timetableXml) }; + // Fourth is full changes + return new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(changesXml) }; + }); + + var httpClient = new HttpClient(mockHandler.Object) { BaseAddress = new Uri(_config.BaseUrl) }; + var service = new TimeTableService(httpClient, _mockOptions.Object); + + // Act + var result = await service.FindTrainConnectionsAsync("Frankfurt", "Konstanz"); + + // Assert + Assert.Contains("Train Connection Analysis", result); + Assert.Contains("⚠ No direct connections found in the current timetable.", result); + Assert.Contains("This could mean:", result); + Assert.Contains("- No direct trains operate between these stations", result); + Assert.Contains("- Trains may require a transfer", result); + Assert.Contains("- Try a different time or date", result); + } }