Skip to content

Commit ddfa3f7

Browse files
Add timezone parameter to calendar tools for dual UTC/local time output (#40)
* Initial plan * Add timezone parameter to calendar tools for dual UTC/local time output - Add TimeZoneHelper utility for timezone conversion - GetCalendarEventsTool: require timeZone parameter, return start_utc/start_local/end_utc/end_local - GetCalendarEventDetailsTool: require timeZone parameter, return start_utc/start_local/end_utc/end_local/timezone - Add TimeZoneHelperTests for conversion logic - Update existing tool tests for new signatures Co-authored-by: rockfordlhotka <2333134+rockfordlhotka@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rockfordlhotka <2333134+rockfordlhotka@users.noreply.github.com>
1 parent 95589b8 commit ddfa3f7

File tree

6 files changed

+228
-22
lines changed

6 files changed

+228
-22
lines changed

src/CalendarMcp.Core/Tools/GetCalendarEventDetailsTool.cs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.ComponentModel;
22
using System.Text.Json;
33
using CalendarMcp.Core.Services;
4+
using CalendarMcp.Core.Utilities;
45
using Microsoft.Extensions.Logging;
56
using ModelContextProtocol.Server;
67

@@ -18,15 +19,25 @@ public sealed class GetCalendarEventDetailsTool(
1819
{
1920
[McpServerTool, Description("Get full details for a single calendar event including attendee responses, free/busy status, recurrence pattern, and online meeting link. Use this after get_calendar_events to fetch richer data for a specific event.")]
2021
public async Task<string> GetCalendarEventDetails(
22+
[Description("IANA timezone name for displaying event times (e.g. 'America/Chicago', 'America/New_York', 'Europe/London', 'Asia/Tokyo'). All event times are returned in both UTC and this local timezone.")] string timeZone,
2123
[Description("Account ID from get_calendar_events")] string accountId,
2224
[Description("Calendar ID from get_calendar_events, or 'primary' for the default calendar")] string calendarId,
2325
[Description("Event ID from get_calendar_events")] string eventId)
2426
{
25-
logger.LogInformation("Getting calendar event details: accountId={AccountId}, calendarId={CalendarId}, eventId={EventId}",
26-
accountId, calendarId, eventId);
27+
logger.LogInformation("Getting calendar event details: accountId={AccountId}, calendarId={CalendarId}, eventId={EventId}, timeZone={TimeZone}",
28+
accountId, calendarId, eventId, timeZone);
2729

2830
try
2931
{
32+
var tz = TimeZoneHelper.TryGetTimeZone(timeZone);
33+
if (tz == null)
34+
{
35+
return JsonSerializer.Serialize(new
36+
{
37+
error = $"Invalid IANA timezone: '{timeZone}'. Use a valid IANA timezone name such as 'America/Chicago', 'Europe/London', or 'Asia/Tokyo'."
38+
});
39+
}
40+
3041
if (string.IsNullOrEmpty(accountId))
3142
{
3243
return JsonSerializer.Serialize(new
@@ -73,8 +84,11 @@ public async Task<string> GetCalendarEventDetails(
7384
accountId = evt.AccountId,
7485
calendarId = evt.CalendarId,
7586
subject = evt.Subject,
76-
start = evt.Start,
77-
end = evt.End,
87+
start_utc = TimeZoneHelper.ToUtcString(evt.Start),
88+
start_local = TimeZoneHelper.ToLocalString(evt.Start, tz),
89+
end_utc = TimeZoneHelper.ToUtcString(evt.End),
90+
end_local = TimeZoneHelper.ToLocalString(evt.End, tz),
91+
timezone = timeZone,
7892
location = evt.Location,
7993
body = evt.Body,
8094
bodyFormat = evt.BodyFormat,

src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Text.Json;
33
using CalendarMcp.Core.Models;
44
using CalendarMcp.Core.Services;
5+
using CalendarMcp.Core.Utilities;
56
using Microsoft.Extensions.Logging;
67
using ModelContextProtocol.Server;
78

@@ -16,19 +17,29 @@ public sealed class GetCalendarEventsTool(
1617
IProviderServiceFactory providerFactory,
1718
ILogger<GetCalendarEventsTool> logger)
1819
{
19-
[McpServerTool, Description("Get calendar events for a date range from one or all accounts. Returns events sorted by start time, each with: id, accountId, calendarId, subject, start, end, location, attendees, isAllDay, organizer. Use the returned accountId and id when calling delete_event, respond_to_event, or get_calendar_event_details.")]
20+
[McpServerTool, Description("Get calendar events for a date range from one or all accounts. Returns events sorted by start time, each with: id, accountId, calendarId, subject, start/end in both UTC and local time, timezone, location, attendees, isAllDay, organizer. Use the returned accountId and id when calling delete_event, respond_to_event, or get_calendar_event_details.")]
2021
public async Task<string> GetCalendarEvents(
22+
[Description("IANA timezone name for displaying event times (e.g. 'America/Chicago', 'America/New_York', 'Europe/London', 'Asia/Tokyo'). All event times are returned in both UTC and this local timezone.")] string timeZone,
2123
[Description("Start of the date range (ISO 8601 format, e.g. '2026-02-20'). Defaults to today.")] DateTime? startDate = null,
2224
[Description("End of the date range (ISO 8601 format, e.g. '2026-02-27'). Defaults to 7 days after startDate.")] DateTime? endDate = null,
2325
[Description("Account ID to query, or omit to query all accounts. Obtain from list_accounts.")] string? accountId = null,
2426
[Description("Calendar ID to query, or omit for all calendars. Obtain from list_calendars.")] string? calendarId = null,
2527
[Description("Maximum number of events to return per account (default 50)")] int count = 50)
2628
{
29+
var tz = TimeZoneHelper.TryGetTimeZone(timeZone);
30+
if (tz == null)
31+
{
32+
return JsonSerializer.Serialize(new
33+
{
34+
error = $"Invalid IANA timezone: '{timeZone}'. Use a valid IANA timezone name such as 'America/Chicago', 'Europe/London', or 'Asia/Tokyo'."
35+
});
36+
}
37+
2738
var resolvedStart = startDate ?? DateTime.Today;
2839
var resolvedEnd = endDate ?? resolvedStart.AddDays(7);
2940

30-
logger.LogInformation("Getting calendar events: startDate={StartDate}, endDate={EndDate}, accountId={AccountId}, count={Count}",
31-
resolvedStart, resolvedEnd, accountId, count);
41+
logger.LogInformation("Getting calendar events: startDate={StartDate}, endDate={EndDate}, accountId={AccountId}, count={Count}, timeZone={TimeZone}",
42+
resolvedStart, resolvedEnd, accountId, count, timeZone);
3243

3344
try
3445
{
@@ -76,14 +87,17 @@ public async Task<string> GetCalendarEvents(
7687

7788
var response = new
7889
{
90+
timezone = timeZone,
7991
events = allEvents.Select(e => new
8092
{
8193
id = e.Id,
8294
accountId = e.AccountId,
8395
calendarId = e.CalendarId,
8496
subject = e.Subject,
85-
start = e.Start,
86-
end = e.End,
97+
start_utc = TimeZoneHelper.ToUtcString(e.Start),
98+
start_local = TimeZoneHelper.ToLocalString(e.Start, tz),
99+
end_utc = TimeZoneHelper.ToUtcString(e.End),
100+
end_local = TimeZoneHelper.ToLocalString(e.End, tz),
87101
location = e.Location,
88102
attendees = e.Attendees,
89103
isAllDay = e.IsAllDay,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
namespace CalendarMcp.Core.Utilities;
2+
3+
/// <summary>
4+
/// Helper methods for converting DateTimeOffset values to UTC and local time representations.
5+
/// Used by calendar tools to provide consistent, timezone-aware date/time output.
6+
/// </summary>
7+
public static class TimeZoneHelper
8+
{
9+
/// <summary>
10+
/// Converts a DateTimeOffset to a formatted UTC string (ISO 8601 with Z suffix).
11+
/// </summary>
12+
public static string ToUtcString(DateTimeOffset dto)
13+
{
14+
return dto.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ");
15+
}
16+
17+
/// <summary>
18+
/// Converts a DateTimeOffset to a formatted local time string (ISO 8601 without offset)
19+
/// in the specified IANA time zone.
20+
/// </summary>
21+
public static string ToLocalString(DateTimeOffset dto, TimeZoneInfo timeZone)
22+
{
23+
var localTime = TimeZoneInfo.ConvertTime(dto, timeZone);
24+
return localTime.DateTime.ToString("yyyy-MM-ddTHH:mm:ss");
25+
}
26+
27+
/// <summary>
28+
/// Tries to find a TimeZoneInfo by IANA timezone ID. Returns null if invalid.
29+
/// </summary>
30+
public static TimeZoneInfo? TryGetTimeZone(string? timeZoneId)
31+
{
32+
if (string.IsNullOrWhiteSpace(timeZoneId))
33+
return null;
34+
35+
try
36+
{
37+
return TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
38+
}
39+
catch (TimeZoneNotFoundException)
40+
{
41+
return null;
42+
}
43+
catch (InvalidTimeZoneException)
44+
{
45+
return null;
46+
}
47+
}
48+
}

src/CalendarMcp.Tests/Tools/GetCalendarEventDetailsToolTests.cs

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,22 @@ namespace CalendarMcp.Tests.Tools;
1111
[TestClass]
1212
public class GetCalendarEventDetailsToolTests
1313
{
14+
private const string TestTimeZone = "America/Chicago";
15+
16+
[TestMethod]
17+
public async Task GetCalendarEventDetails_InvalidTimeZone_ReturnsError()
18+
{
19+
var regExp = new IAccountRegistryCreateExpectations();
20+
var factExp = new IProviderServiceFactoryCreateExpectations();
21+
var tool = new GetCalendarEventDetailsTool(regExp.Instance(), factExp.Instance(),
22+
NullLogger<GetCalendarEventDetailsTool>.Instance);
23+
24+
var result = await tool.GetCalendarEventDetails("Invalid/Zone", "acc-1", "cal-1", "ev-1");
25+
var doc = JsonDocument.Parse(result);
26+
27+
Assert.IsTrue(doc.RootElement.GetProperty("error").GetString()!.Contains("Invalid IANA timezone"));
28+
}
29+
1430
[TestMethod]
1531
public async Task GetCalendarEventDetails_EmptyAccountId_ReturnsError()
1632
{
@@ -19,7 +35,7 @@ public async Task GetCalendarEventDetails_EmptyAccountId_ReturnsError()
1935
var tool = new GetCalendarEventDetailsTool(regExp.Instance(), factExp.Instance(),
2036
NullLogger<GetCalendarEventDetailsTool>.Instance);
2137

22-
var result = await tool.GetCalendarEventDetails("", "cal-1", "ev-1");
38+
var result = await tool.GetCalendarEventDetails(TestTimeZone, "", "cal-1", "ev-1");
2339
var doc = JsonDocument.Parse(result);
2440

2541
Assert.AreEqual("accountId is required", doc.RootElement.GetProperty("error").GetString());
@@ -33,7 +49,7 @@ public async Task GetCalendarEventDetails_EmptyEventId_ReturnsError()
3349
var tool = new GetCalendarEventDetailsTool(regExp.Instance(), factExp.Instance(),
3450
NullLogger<GetCalendarEventDetailsTool>.Instance);
3551

36-
var result = await tool.GetCalendarEventDetails("acc-1", "cal-1", "");
52+
var result = await tool.GetCalendarEventDetails(TestTimeZone, "acc-1", "cal-1", "");
3753
var doc = JsonDocument.Parse(result);
3854

3955
Assert.AreEqual("eventId is required", doc.RootElement.GetProperty("error").GetString());
@@ -50,7 +66,7 @@ public async Task GetCalendarEventDetails_AccountNotFound_ReturnsError()
5066
var tool = new GetCalendarEventDetailsTool(regExp.Instance(), factExp.Instance(),
5167
NullLogger<GetCalendarEventDetailsTool>.Instance);
5268

53-
var result = await tool.GetCalendarEventDetails("nonexistent", "cal-1", "ev-1");
69+
var result = await tool.GetCalendarEventDetails(TestTimeZone, "nonexistent", "cal-1", "ev-1");
5470
var doc = JsonDocument.Parse(result);
5571

5672
Assert.AreEqual("Account 'nonexistent' not found", doc.RootElement.GetProperty("error").GetString());
@@ -76,7 +92,7 @@ public async Task GetCalendarEventDetails_EventNotFound_ReturnsError()
7692
var tool = new GetCalendarEventDetailsTool(regExp.Instance(), factExp.Instance(),
7793
NullLogger<GetCalendarEventDetailsTool>.Instance);
7894

79-
var result = await tool.GetCalendarEventDetails("acc-1", "cal-1", "missing");
95+
var result = await tool.GetCalendarEventDetails(TestTimeZone, "acc-1", "cal-1", "missing");
8096
var doc = JsonDocument.Parse(result);
8197

8298
Assert.IsTrue(doc.RootElement.GetProperty("error").GetString()!.Contains("not found"));
@@ -86,10 +102,12 @@ public async Task GetCalendarEventDetails_EventNotFound_ReturnsError()
86102
}
87103

88104
[TestMethod]
89-
public async Task GetCalendarEventDetails_Success_ReturnsEventJson()
105+
public async Task GetCalendarEventDetails_Success_ReturnsEventJsonWithTimezone()
90106
{
91107
var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365");
92-
var evt = TestData.CreateEvent(id: "ev-1", accountId: "acc-1", subject: "Team Standup");
108+
var evt = TestData.CreateEvent(id: "ev-1", accountId: "acc-1", subject: "Team Standup",
109+
start: new DateTime(2025, 1, 10, 15, 0, 0, DateTimeKind.Utc),
110+
end: new DateTime(2025, 1, 10, 16, 0, 0, DateTimeKind.Utc));
93111

94112
var regExp = new IAccountRegistryCreateExpectations();
95113
regExp.Setups.GetAccountAsync("acc-1")
@@ -105,11 +123,21 @@ public async Task GetCalendarEventDetails_Success_ReturnsEventJson()
105123
var tool = new GetCalendarEventDetailsTool(regExp.Instance(), factExp.Instance(),
106124
NullLogger<GetCalendarEventDetailsTool>.Instance);
107125

108-
var result = await tool.GetCalendarEventDetails("acc-1", "cal-1", "ev-1");
126+
var result = await tool.GetCalendarEventDetails(TestTimeZone, "acc-1", "cal-1", "ev-1");
109127
var doc = JsonDocument.Parse(result);
110128

111129
Assert.AreEqual("ev-1", doc.RootElement.GetProperty("id").GetString());
112130
Assert.AreEqual("Team Standup", doc.RootElement.GetProperty("subject").GetString());
131+
Assert.AreEqual(TestTimeZone, doc.RootElement.GetProperty("timezone").GetString());
132+
133+
// Verify UTC and local time fields are present
134+
Assert.IsTrue(doc.RootElement.TryGetProperty("start_utc", out _));
135+
Assert.IsTrue(doc.RootElement.TryGetProperty("start_local", out _));
136+
Assert.IsTrue(doc.RootElement.TryGetProperty("end_utc", out _));
137+
Assert.IsTrue(doc.RootElement.TryGetProperty("end_local", out _));
138+
139+
// Verify UTC times end with Z
140+
Assert.IsTrue(doc.RootElement.GetProperty("start_utc").GetString()!.EndsWith("Z"));
113141

114142
regExp.Verify();
115143
factExp.Verify();

src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@ public class GetCalendarEventsToolTests
1313
{
1414
private static readonly DateTime Start = new(2025, 1, 1);
1515
private static readonly DateTime End = new(2025, 1, 31);
16+
private const string TestTimeZone = "America/Chicago";
17+
18+
[TestMethod]
19+
public async Task GetCalendarEvents_InvalidTimeZone_ReturnsError()
20+
{
21+
var regExp = new IAccountRegistryCreateExpectations();
22+
var factExp = new IProviderServiceFactoryCreateExpectations();
23+
var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
24+
NullLogger<GetCalendarEventsTool>.Instance);
25+
26+
var result = await tool.GetCalendarEvents("Invalid/Zone", Start, End);
27+
var doc = JsonDocument.Parse(result);
28+
29+
Assert.IsTrue(doc.RootElement.GetProperty("error").GetString()!.Contains("Invalid IANA timezone"));
30+
}
1631

1732
[TestMethod]
1833
public async Task GetCalendarEvents_AccountNotFound_ReturnsError()
@@ -25,20 +40,22 @@ public async Task GetCalendarEvents_AccountNotFound_ReturnsError()
2540
var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
2641
NullLogger<GetCalendarEventsTool>.Instance);
2742

28-
var result = await tool.GetCalendarEvents(Start, End, "nonexistent");
43+
var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, "nonexistent");
2944
var doc = JsonDocument.Parse(result);
3045

3146
Assert.AreEqual("Account 'nonexistent' not found", doc.RootElement.GetProperty("error").GetString());
3247
regExp.Verify();
3348
}
3449

3550
[TestMethod]
36-
public async Task GetCalendarEvents_SpecificAccount_ReturnsEvents()
51+
public async Task GetCalendarEvents_SpecificAccount_ReturnsEventsWithTimezone()
3752
{
3853
var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365");
3954
var events = new List<CalendarEvent>
4055
{
41-
TestData.CreateEvent(id: "ev1", accountId: "acc-1", subject: "Meeting")
56+
TestData.CreateEvent(id: "ev1", accountId: "acc-1", subject: "Meeting",
57+
start: new DateTime(2025, 1, 10, 15, 0, 0, DateTimeKind.Utc),
58+
end: new DateTime(2025, 1, 10, 16, 0, 0, DateTimeKind.Utc))
4259
};
4360

4461
var regExp = new IAccountRegistryCreateExpectations();
@@ -58,12 +75,26 @@ public async Task GetCalendarEvents_SpecificAccount_ReturnsEvents()
5875
var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
5976
NullLogger<GetCalendarEventsTool>.Instance);
6077

61-
var result = await tool.GetCalendarEvents(Start, End, "acc-1");
78+
var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, "acc-1");
6279
var doc = JsonDocument.Parse(result);
6380
var eventsArray = doc.RootElement.GetProperty("events");
6481

6582
Assert.AreEqual(1, eventsArray.GetArrayLength());
6683
Assert.AreEqual("ev1", eventsArray[0].GetProperty("id").GetString());
84+
Assert.AreEqual(TestTimeZone, doc.RootElement.GetProperty("timezone").GetString());
85+
86+
// Verify UTC and local time fields are present
87+
Assert.IsTrue(eventsArray[0].TryGetProperty("start_utc", out _));
88+
Assert.IsTrue(eventsArray[0].TryGetProperty("start_local", out _));
89+
Assert.IsTrue(eventsArray[0].TryGetProperty("end_utc", out _));
90+
Assert.IsTrue(eventsArray[0].TryGetProperty("end_local", out _));
91+
92+
// Verify UTC times end with Z
93+
Assert.IsTrue(eventsArray[0].GetProperty("start_utc").GetString()!.EndsWith("Z"));
94+
Assert.IsTrue(eventsArray[0].GetProperty("end_utc").GetString()!.EndsWith("Z"));
95+
96+
// Verify local times don't end with Z
97+
Assert.IsFalse(eventsArray[0].GetProperty("start_local").GetString()!.EndsWith("Z"));
6798

6899
regExp.Verify();
69100
factExp.Verify();
@@ -104,13 +135,14 @@ public async Task GetCalendarEvents_AllAccounts_ReturnsEventsSortedByStart()
104135
var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
105136
NullLogger<GetCalendarEventsTool>.Instance);
106137

107-
var result = await tool.GetCalendarEvents(Start, End);
138+
var result = await tool.GetCalendarEvents(TestTimeZone, Start, End);
108139
var doc = JsonDocument.Parse(result);
109140
var eventsArray = doc.RootElement.GetProperty("events");
110141

111142
Assert.AreEqual(2, eventsArray.GetArrayLength());
112143
Assert.AreEqual("ev1", eventsArray[0].GetProperty("id").GetString());
113144
Assert.AreEqual("ev2", eventsArray[1].GetProperty("id").GetString());
145+
Assert.AreEqual(TestTimeZone, doc.RootElement.GetProperty("timezone").GetString());
114146

115147
regExp.Verify();
116148
factExp.Verify();
@@ -129,7 +161,7 @@ public async Task GetCalendarEvents_NoAccounts_ReturnsError()
129161
var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
130162
NullLogger<GetCalendarEventsTool>.Instance);
131163

132-
var result = await tool.GetCalendarEvents(Start, End);
164+
var result = await tool.GetCalendarEvents(TestTimeZone, Start, End);
133165
var doc = JsonDocument.Parse(result);
134166

135167
Assert.AreEqual("No accounts found", doc.RootElement.GetProperty("error").GetString());

0 commit comments

Comments
 (0)