diff --git a/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs b/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs
index 6bcd616..f9de8d6 100644
--- a/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs
+++ b/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs
@@ -360,4 +360,282 @@ 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 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("Frankfurt Hbf", result);
+ 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()
+ {
+ // 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);
+ }
+
+ [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);
+ }
}
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..4de5189 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,413 @@ 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 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 GetFullChangesAsync(stationAInfo.EvaNo, cancellationToken);
+ result.AppendLine(" ✓ Full changes retrieved");
+ }
+ catch
+ {
+ changesA = string.Empty;
+ result.AppendLine(" ⚠ No 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
+ // 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 ??
+ 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
+ // 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) ||
+ (!string.IsNullOrEmpty(stationBFirstWord) && ps.Contains(stationBFirstWord, StringComparison.OrdinalIgnoreCase)));
+
+ if (!goesToDestination &&
+ (string.IsNullOrEmpty(stationBFirstWord) || !destination.Contains(stationBFirstWord, 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 ?? "";
+
+ 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);
+
+ // Look up live changes for this specific train from the full changes data
+ int delay = 0;
+ 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;
+ }
+
+ 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)
+ /// Example: "2511061430" represents 2025-11-06 14:30 (YY=25, MM=11, dd=06, HH=14, mm=30)
+ ///
+ 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;
+ }
+
+ ///
+ /// 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
+ ///
+ 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}";
+ }
+ }
}
}
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: