Skip to content

Commit 493e6d2

Browse files
Fix timezone handling across all calendar providers and tools (#41)
- Fix Google CreateEventAsync/UpdateEventAsync: replace DateTimeOffset(DateTime) constructor (which embeds local system UTC offset) with DateTimeRaw string (no offset), so Google Calendar honors the timeZone field rather than treating the time as UTC - Fix UpdateEventAsync in all providers: replace TimeZoneInfo.Local.Id with the caller-supplied timeZone parameter; add timeZone to IProviderService interface and all implementations (ICS/JSON stubs included) - Add missing UpdateEventTool and UpdateEventToolTests - Fix GetCalendarEventsTool: replace DateTime.Today (server local) with TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz).Date so "today" defaults to the caller's timezone, not the server's - Fix Google GetCalendarEventsAsync: use DateTimeOffset(start, TimeSpan.Zero) instead of DateTimeOffset(start) to make UTC intent explicit and server-TZ-independent - Fix M365/OutlookCom GetCalendarEventDetailsAsync: use ParseM365DateTime() helper (already used in the list path) instead of raw DateTime.TryParse, so the event timezone field is honored when mapping Start/End - Fix JsonCalendarProviderService: use DateTimeStyles.RoundtripKind when parsing CreatedDateTime/LastModifiedDateTime so embedded UTC offsets are preserved before converting to universal time Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 8c24e86 commit 493e6d2

9 files changed

Lines changed: 185 additions & 48 deletions

File tree

src/CalendarMcp.Core/Providers/GoogleProviderService.cs

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -550,8 +550,8 @@ public async Task<IEnumerable<CalendarEvent>> GetCalendarEventsAsync(
550550
var targetCalendarId = calendarId ?? "primary";
551551

552552
var request = service.Events.List(targetCalendarId);
553-
request.TimeMinDateTimeOffset = new DateTimeOffset(start);
554-
request.TimeMaxDateTimeOffset = new DateTimeOffset(end);
553+
request.TimeMinDateTimeOffset = new DateTimeOffset(start, TimeSpan.Zero);
554+
request.TimeMaxDateTimeOffset = new DateTimeOffset(end, TimeSpan.Zero);
555555
request.MaxResults = count;
556556
request.OrderBy = EventsResource.ListRequest.OrderByEnum.StartTime;
557557
request.SingleEvents = true;
@@ -696,12 +696,12 @@ public async Task<string> CreateEventAsync(
696696
Location = location,
697697
Start = new EventDateTime
698698
{
699-
DateTimeDateTimeOffset = new DateTimeOffset(start),
699+
DateTimeRaw = start.ToString("yyyy-MM-ddTHH:mm:ss"),
700700
TimeZone = timeZone ?? "UTC"
701701
},
702702
End = new EventDateTime
703703
{
704-
DateTimeDateTimeOffset = new DateTimeOffset(end),
704+
DateTimeRaw = end.ToString("yyyy-MM-ddTHH:mm:ss"),
705705
TimeZone = timeZone ?? "UTC"
706706
}
707707
};
@@ -728,14 +728,15 @@ public async Task<string> CreateEventAsync(
728728
}
729729

730730
public async Task UpdateEventAsync(
731-
string accountId,
732-
string calendarId,
733-
string eventId,
734-
string? subject = null,
735-
DateTime? start = null,
736-
DateTime? end = null,
737-
string? location = null,
738-
List<string>? attendees = null,
731+
string accountId,
732+
string calendarId,
733+
string eventId,
734+
string? subject = null,
735+
DateTime? start = null,
736+
DateTime? end = null,
737+
string? location = null,
738+
List<string>? attendees = null,
739+
string? timeZone = null,
739740
CancellationToken cancellationToken = default)
740741
{
741742
var credential = await GetCredentialAsync(accountId, cancellationToken);
@@ -764,16 +765,16 @@ public async Task UpdateEventAsync(
764765
{
765766
existingEvent.Start = new EventDateTime
766767
{
767-
DateTimeDateTimeOffset = new DateTimeOffset(start.Value),
768-
TimeZone = TimeZoneInfo.Local.Id
768+
DateTimeRaw = start.Value.ToString("yyyy-MM-ddTHH:mm:ss"),
769+
TimeZone = timeZone ?? "UTC"
769770
};
770771
}
771772
if (end.HasValue)
772773
{
773774
existingEvent.End = new EventDateTime
774775
{
775-
DateTimeDateTimeOffset = new DateTimeOffset(end.Value),
776-
TimeZone = TimeZoneInfo.Local.Id
776+
DateTimeRaw = end.Value.ToString("yyyy-MM-ddTHH:mm:ss"),
777+
TimeZone = timeZone ?? "UTC"
777778
};
778779
}
779780
if (attendees != null)

src/CalendarMcp.Core/Providers/IcsProviderService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ public Task UpdateEventAsync(
301301
string accountId, string calendarId, string eventId,
302302
string? subject = null, DateTime? start = null, DateTime? end = null,
303303
string? location = null, List<string>? attendees = null,
304-
CancellationToken cancellationToken = default)
304+
string? timeZone = null, CancellationToken cancellationToken = default)
305305
=> throw new NotSupportedException("ICS provider is read-only.");
306306

307307
public Task DeleteEventAsync(

src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ public Task UpdateEventAsync(
357357
string accountId, string calendarId, string eventId,
358358
string? subject = null, DateTime? start = null, DateTime? end = null,
359359
string? location = null, List<string>? attendees = null,
360-
CancellationToken cancellationToken = default)
360+
string? timeZone = null, CancellationToken cancellationToken = default)
361361
=> throw new NotSupportedException("JSON calendar provider is read-only.");
362362

363363
public Task DeleteEventAsync(
@@ -488,16 +488,16 @@ private CalendarEvent MapToCalendarEvent(
488488
// Parse categories
489489
var categories = entry.Categories ?? new List<string>();
490490

491-
// Parse dates
491+
// Parse dates — use RoundtripKind to honour any UTC/offset info embedded in the string
492492
DateTime? createdDateTime = null;
493493
if (!string.IsNullOrEmpty(entry.CreatedDateTime) &&
494-
DateTime.TryParse(entry.CreatedDateTime, out var created))
495-
createdDateTime = created.ToUniversalTime();
494+
DateTime.TryParse(entry.CreatedDateTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var created))
495+
createdDateTime = created.Kind == DateTimeKind.Unspecified ? created : created.ToUniversalTime();
496496

497497
DateTime? lastModifiedDateTime = null;
498498
if (!string.IsNullOrEmpty(entry.LastModifiedDateTime) &&
499-
DateTime.TryParse(entry.LastModifiedDateTime, out var modified))
500-
lastModifiedDateTime = modified.ToUniversalTime();
499+
DateTime.TryParse(entry.LastModifiedDateTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var modified))
500+
lastModifiedDateTime = modified.Kind == DateTimeKind.Unspecified ? modified : modified.ToUniversalTime();
501501

502502
// Detect online meeting URL from body or webLink
503503
string? onlineMeetingUrl = null;

src/CalendarMcp.Core/Providers/M365ProviderService.cs

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -637,8 +637,8 @@ public async Task<IEnumerable<CalendarEvent>> GetCalendarEventsAsync(
637637
AccountId = accountId,
638638
CalendarId = calendarId ?? "primary",
639639
Subject = evt.Subject ?? string.Empty,
640-
Start = DateTime.TryParse(evt.Start?.DateTime, out var startDt) ? startDt : DateTime.MinValue,
641-
End = DateTime.TryParse(evt.End?.DateTime, out var endDt) ? endDt : DateTime.MinValue,
640+
Start = ParseM365DateTime(evt.Start),
641+
End = ParseM365DateTime(evt.End),
642642
Location = evt.Location?.DisplayName ?? string.Empty,
643643
Body = evt.Body?.Content ?? string.Empty,
644644
BodyFormat = evt.Body?.ContentType == BodyType.Html ? "html" : "text",
@@ -912,14 +912,15 @@ public async Task<string> CreateEventAsync(
912912
}
913913

914914
public async Task UpdateEventAsync(
915-
string accountId,
916-
string calendarId,
917-
string eventId,
918-
string? subject = null,
919-
DateTime? start = null,
920-
DateTime? end = null,
921-
string? location = null,
922-
List<string>? attendees = null,
915+
string accountId,
916+
string calendarId,
917+
string eventId,
918+
string? subject = null,
919+
DateTime? start = null,
920+
DateTime? end = null,
921+
string? location = null,
922+
List<string>? attendees = null,
923+
string? timeZone = null,
923924
CancellationToken cancellationToken = default)
924925
{
925926
var token = await GetAccessTokenAsync(accountId, cancellationToken);
@@ -945,7 +946,7 @@ public async Task UpdateEventAsync(
945946
eventUpdate.Start = new DateTimeTimeZone
946947
{
947948
DateTime = start.Value.ToString("yyyy-MM-ddTHH:mm:ss"),
948-
TimeZone = TimeZoneInfo.Local.Id
949+
TimeZone = timeZone ?? "UTC"
949950
};
950951
}
951952

@@ -954,7 +955,7 @@ public async Task UpdateEventAsync(
954955
eventUpdate.End = new DateTimeTimeZone
955956
{
956957
DateTime = end.Value.ToString("yyyy-MM-ddTHH:mm:ss"),
957-
TimeZone = TimeZoneInfo.Local.Id
958+
TimeZone = timeZone ?? "UTC"
958959
};
959960
}
960961

src/CalendarMcp.Core/Providers/OutlookComProviderService.cs

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -628,8 +628,8 @@ public async Task<IEnumerable<CalendarEvent>> GetCalendarEventsAsync(
628628
AccountId = accountId,
629629
CalendarId = calendarId ?? "primary",
630630
Subject = evt.Subject ?? string.Empty,
631-
Start = DateTime.TryParse(evt.Start?.DateTime, out var startDt) ? startDt : DateTime.MinValue,
632-
End = DateTime.TryParse(evt.End?.DateTime, out var endDt) ? endDt : DateTime.MinValue,
631+
Start = ParseM365DateTime(evt.Start),
632+
End = ParseM365DateTime(evt.End),
633633
Location = evt.Location?.DisplayName ?? string.Empty,
634634
Body = evt.Body?.Content ?? string.Empty,
635635
BodyFormat = evt.Body?.ContentType == BodyType.Html ? "html" : "text",
@@ -902,14 +902,15 @@ public async Task<string> CreateEventAsync(
902902
}
903903

904904
public async Task UpdateEventAsync(
905-
string accountId,
906-
string calendarId,
907-
string eventId,
908-
string? subject = null,
909-
DateTime? start = null,
910-
DateTime? end = null,
911-
string? location = null,
912-
List<string>? attendees = null,
905+
string accountId,
906+
string calendarId,
907+
string eventId,
908+
string? subject = null,
909+
DateTime? start = null,
910+
DateTime? end = null,
911+
string? location = null,
912+
List<string>? attendees = null,
913+
string? timeZone = null,
913914
CancellationToken cancellationToken = default)
914915
{
915916
var token = await GetAccessTokenAsync(accountId, cancellationToken);
@@ -935,7 +936,7 @@ public async Task UpdateEventAsync(
935936
eventUpdate.Start = new DateTimeTimeZone
936937
{
937938
DateTime = start.Value.ToString("yyyy-MM-ddTHH:mm:ss"),
938-
TimeZone = TimeZoneInfo.Local.Id
939+
TimeZone = timeZone ?? "UTC"
939940
};
940941
}
941942

@@ -944,7 +945,7 @@ public async Task UpdateEventAsync(
944945
eventUpdate.End = new DateTimeTimeZone
945946
{
946947
DateTime = end.Value.ToString("yyyy-MM-ddTHH:mm:ss"),
947-
TimeZone = TimeZoneInfo.Local.Id
948+
TimeZone = timeZone ?? "UTC"
948949
};
949950
}
950951

src/CalendarMcp.Core/Services/IProviderService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ Task UpdateEventAsync(
9393
DateTime? end = null,
9494
string? location = null,
9595
List<string>? attendees = null,
96+
string? timeZone = null,
9697
CancellationToken cancellationToken = default);
9798

9899
Task DeleteEventAsync(

src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public async Task<string> GetCalendarEvents(
3535
});
3636
}
3737

38-
var resolvedStart = startDate ?? DateTime.Today;
38+
var resolvedStart = startDate ?? TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz).Date;
3939
var resolvedEnd = endDate ?? resolvedStart.AddDays(7);
4040

4141
logger.LogInformation("Getting calendar events: startDate={StartDate}, endDate={EndDate}, accountId={AccountId}, count={Count}, timeZone={TimeZone}",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using System.ComponentModel;
2+
using System.Text.Json;
3+
using CalendarMcp.Core.Services;
4+
using Microsoft.Extensions.Logging;
5+
using ModelContextProtocol.Server;
6+
7+
namespace CalendarMcp.Core.Tools;
8+
9+
/// <summary>
10+
/// MCP tool for updating calendar events
11+
/// </summary>
12+
[McpServerToolType]
13+
public sealed class UpdateEventTool(
14+
IAccountRegistry accountRegistry,
15+
IProviderServiceFactory providerFactory,
16+
ILogger<UpdateEventTool> logger)
17+
{
18+
[McpServerTool, Description("Update an existing calendar event. Always pass the timeZone parameter using the user's local IANA timezone (e.g. `America/Chicago`, `America/New_York`, `Europe/London`) when updating start/end times so events are updated at the correct local time.")]
19+
public async Task<string> UpdateEvent(
20+
[Description("Account ID that owns the event. Obtain from list_accounts.")] string accountId,
21+
[Description("Calendar ID that contains the event. Obtain from list_calendars.")] string calendarId,
22+
[Description("Event ID to update. Obtain from get_calendar_events or get_calendar_event_details.")] string eventId,
23+
[Description("New event subject/title")] string? subject = null,
24+
[Description("New event start date and time (ISO 8601 format)")] DateTime? start = null,
25+
[Description("New event end date and time (ISO 8601 format)")] DateTime? end = null,
26+
[Description("New event location")] string? location = null,
27+
[Description("New list of attendee email addresses")] List<string>? attendees = null,
28+
[Description("IANA timezone name for the event times (e.g. `America/Chicago`, `America/New_York`, `Europe/London`). Required when updating start or end times.")] string? timeZone = null)
29+
{
30+
logger.LogInformation("Updating event: eventId={EventId}, accountId={AccountId}, calendarId={CalendarId}",
31+
eventId, accountId, calendarId);
32+
33+
try
34+
{
35+
var account = await accountRegistry.GetAccountAsync(accountId);
36+
if (account == null)
37+
{
38+
return JsonSerializer.Serialize(new
39+
{
40+
error = $"Account '{accountId}' not found"
41+
});
42+
}
43+
44+
var provider = providerFactory.GetProvider(account.Provider);
45+
await provider.UpdateEventAsync(
46+
accountId, calendarId, eventId, subject, start, end, location, attendees, timeZone, CancellationToken.None);
47+
48+
return JsonSerializer.Serialize(new
49+
{
50+
success = true,
51+
eventId,
52+
accountUsed = accountId,
53+
calendarUsed = calendarId
54+
}, new JsonSerializerOptions { WriteIndented = true });
55+
}
56+
catch (Exception ex)
57+
{
58+
logger.LogError(ex, "Error in update_event tool");
59+
return JsonSerializer.Serialize(new
60+
{
61+
error = "Failed to update event",
62+
message = ex.Message
63+
});
64+
}
65+
}
66+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
using System.Text.Json;
2+
using CalendarMcp.Core.Models;
3+
using CalendarMcp.Core.Services;
4+
using CalendarMcp.Core.Tools;
5+
using CalendarMcp.Tests.Helpers;
6+
using Microsoft.Extensions.Logging.Abstractions;
7+
using Rocks;
8+
9+
namespace CalendarMcp.Tests.Tools;
10+
11+
[TestClass]
12+
public class UpdateEventToolTests
13+
{
14+
private static readonly DateTime Start = new(2025, 6, 1, 10, 0, 0);
15+
private static readonly DateTime End = new(2025, 6, 1, 11, 0, 0);
16+
17+
[TestMethod]
18+
public async Task UpdateEvent_Success()
19+
{
20+
var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365");
21+
22+
var regExp = new IAccountRegistryCreateExpectations();
23+
regExp.Setups.GetAccountAsync("acc-1")
24+
.ReturnValue(Task.FromResult<AccountInfo?>(account));
25+
26+
var provExp = new IProviderServiceCreateExpectations();
27+
provExp.Setups.UpdateEventAsync(
28+
"acc-1", "cal-1", "ev-1",
29+
Arg.Any<string?>(), Arg.Any<DateTime?>(), Arg.Any<DateTime?>(),
30+
Arg.Any<string?>(), Arg.Any<List<string>?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
31+
.ReturnValue(Task.CompletedTask);
32+
33+
var factExp = new IProviderServiceFactoryCreateExpectations();
34+
factExp.Setups.GetProvider("microsoft365").ReturnValue(provExp.Instance());
35+
36+
var tool = new UpdateEventTool(regExp.Instance(), factExp.Instance(),
37+
NullLogger<UpdateEventTool>.Instance);
38+
39+
var result = await tool.UpdateEvent("acc-1", "cal-1", "ev-1", subject: "Updated", start: Start, end: End, timeZone: "America/Chicago");
40+
var doc = JsonDocument.Parse(result);
41+
42+
Assert.IsTrue(doc.RootElement.GetProperty("success").GetBoolean());
43+
Assert.AreEqual("ev-1", doc.RootElement.GetProperty("eventId").GetString());
44+
45+
regExp.Verify();
46+
factExp.Verify();
47+
provExp.Verify();
48+
}
49+
50+
[TestMethod]
51+
public async Task UpdateEvent_AccountNotFound_ReturnsError()
52+
{
53+
var regExp = new IAccountRegistryCreateExpectations();
54+
regExp.Setups.GetAccountAsync("nonexistent")
55+
.ReturnValue(Task.FromResult<AccountInfo?>(null));
56+
57+
var factExp = new IProviderServiceFactoryCreateExpectations();
58+
var tool = new UpdateEventTool(regExp.Instance(), factExp.Instance(),
59+
NullLogger<UpdateEventTool>.Instance);
60+
61+
var result = await tool.UpdateEvent("nonexistent", "cal-1", "ev-1");
62+
var doc = JsonDocument.Parse(result);
63+
64+
Assert.AreEqual("Account 'nonexistent' not found", doc.RootElement.GetProperty("error").GetString());
65+
regExp.Verify();
66+
}
67+
}

0 commit comments

Comments
 (0)