Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 67 additions & 9 deletions src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ public sealed class GetCalendarEventsTool(
IProviderServiceFactory providerFactory,
ILogger<GetCalendarEventsTool> logger)
{
[McpServerTool, Description("Get calendar events for a date range from one or all accounts. The timeZone parameter is required. 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.")]
[McpServerTool, Description("Get calendar events for a date range from a specific account. The timeZone parameter is required. The accountId parameter is required unless calendarId uniquely identifies a calendar across all accounts, in which case the account is resolved automatically. 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.")]
public async Task<string> GetCalendarEvents(
[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. Required.")] string timeZone,
[Description("Start of the date range (ISO 8601 format, e.g. `2026-02-20`). Defaults to today.")] DateTime? startDate = null,
[Description("End of the date range, inclusive (ISO 8601 format, e.g. `2026-02-27`). Defaults to 7 days after startDate.")] DateTime? endDate = null,
[Description("Account ID to query, or omit to query all accounts. Obtain from list_accounts.")] string? accountId = null,
[Description("Calendar ID to query, or omit for all calendars. Obtain from list_calendars.")] string? calendarId = null,
[Description("Account ID to query. Obtain from list_accounts. Required unless calendarId uniquely identifies a single account.")] string? accountId = null,
[Description("Calendar ID to query, or omit for all calendars. Obtain from list_calendars. If accountId is omitted, calendarId is used to identify the account automatically when it exists in exactly one account.")] string? calendarId = null,
[Description("Maximum number of events to return per account (default 50)")] int count = 50)
{
var tz = TimeZoneHelper.TryGetTimeZone(timeZone);
Expand All @@ -35,6 +35,68 @@ public async Task<string> GetCalendarEvents(
});
}

if (string.IsNullOrEmpty(accountId))
{
if (string.IsNullOrEmpty(calendarId))
{
return JsonSerializer.Serialize(new
{
error = "accountId is required"
});
}

// calendarId provided but no accountId — try to resolve the account automatically
try
{
var allAccounts = accountRegistry.GetEnabledAccounts().ToList();
var lookupTasks = allAccounts.Select(async acc =>
{
try
{
var prov = providerFactory.GetProvider(acc.Provider);
var cals = await prov.ListCalendarsAsync(acc.Id, CancellationToken.None);
return cals.Any(c => c.Id == calendarId) ? acc.Id : null;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error listing calendars for account {AccountId} during calendar lookup", acc.Id);
return null;
}
});

var lookupResults = await Task.WhenAll(lookupTasks);
var matchingAccountIds = lookupResults.OfType<string>().ToList();

if (matchingAccountIds.Count == 0)
{
return JsonSerializer.Serialize(new
{
error = $"No calendar found with id '{calendarId}'. Provide accountId to specify which account to query."
});
}

if (matchingAccountIds.Count > 1)
{
return JsonSerializer.Serialize(new
{
error = $"calendarId '{calendarId}' exists in multiple accounts; provide accountId to specify which account to query."
});
}

accountId = matchingAccountIds[0];
logger.LogInformation("Resolved accountId={AccountId} from calendarId={CalendarId}", accountId, calendarId);
}
catch (Exception ex)
{
logger.LogError(ex, "Error resolving accountId from calendarId {CalendarId}", calendarId);
return JsonSerializer.Serialize(new
{
error = "Failed to resolve account from calendarId",
message = ex.Message
});
}
}

var resolvedStart = startDate ?? TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz).Date;
var resolvedEnd = endDate.HasValue ? endDate.Value.Date.AddDays(1) : resolvedStart.AddDays(7);

Expand All @@ -43,18 +105,14 @@ public async Task<string> GetCalendarEvents(

try
{
// Determine which accounts to query
var accounts = string.IsNullOrEmpty(accountId)
? accountRegistry.GetEnabledAccounts()
: new[] { await accountRegistry.GetAccountAsync(accountId) }.Where(a => a != null).Cast<AccountInfo>();

var accounts = new[] { await accountRegistry.GetAccountAsync(accountId) }.Where(a => a != null).Cast<AccountInfo>();
var validAccounts = accounts.ToList();

if (validAccounts.Count == 0)
{
return JsonSerializer.Serialize(new
{
error = accountId != null ? $"Account '{accountId}' not found" : "No accounts found"
error = $"Account '{accountId}' not found"
});
}

Expand Down
132 changes: 103 additions & 29 deletions src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,69 +102,143 @@ public async Task GetCalendarEvents_SpecificAccount_ReturnsEventsWithTimezone()
}

[TestMethod]
public async Task GetCalendarEvents_AllAccounts_ReturnsEventsSortedByStart()
public async Task GetCalendarEvents_NullAccountId_ReturnsValidationError()
{
var acc1 = TestData.CreateAccount(id: "acc-1", provider: "microsoft365");
var acc2 = TestData.CreateAccount(id: "acc-2", provider: "google");
var regExp = new IAccountRegistryCreateExpectations();
var factExp = new IProviderServiceFactoryCreateExpectations();
var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
NullLogger<GetCalendarEventsTool>.Instance);

var earlyEvent = TestData.CreateEvent(id: "ev1", accountId: "acc-1",
start: new DateTime(2025, 1, 10), end: new DateTime(2025, 1, 10, 1, 0, 0));
var lateEvent = TestData.CreateEvent(id: "ev2", accountId: "acc-2",
start: new DateTime(2025, 1, 20), end: new DateTime(2025, 1, 20, 1, 0, 0));
var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, null);
var doc = JsonDocument.Parse(result);

Assert.AreEqual("accountId is required", doc.RootElement.GetProperty("error").GetString());
}

[TestMethod]
public async Task GetCalendarEvents_EmptyAccountId_ReturnsValidationError()
{
var regExp = new IAccountRegistryCreateExpectations();
regExp.Setups.GetEnabledAccounts()
.ReturnValue([acc1, acc2]);
var factExp = new IProviderServiceFactoryCreateExpectations();
var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
NullLogger<GetCalendarEventsTool>.Instance);

var prov1Exp = new IProviderServiceCreateExpectations();
prov1Exp.Setups.GetCalendarEventsAsync(
var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, "");
var doc = JsonDocument.Parse(result);

Assert.AreEqual("accountId is required", doc.RootElement.GetProperty("error").GetString());
}

[TestMethod]
public async Task GetCalendarEvents_NullAccountIdWithCalendarId_SingleMatch_ResolvesAccount()
{
var acc1 = TestData.CreateAccount(id: "acc-1", provider: "microsoft365");
var calendars = new List<CalendarInfo> { TestData.CreateCalendar(id: "cal-work", accountId: "acc-1") };
var events = new List<CalendarEvent>
{
TestData.CreateEvent(id: "ev1", accountId: "acc-1", subject: "Meeting",
start: new DateTime(2025, 1, 10, 15, 0, 0, DateTimeKind.Utc),
end: new DateTime(2025, 1, 10, 16, 0, 0, DateTimeKind.Utc))
};

var regExp = new IAccountRegistryCreateExpectations();
regExp.Setups.GetEnabledAccounts().ReturnValue([acc1]);
regExp.Setups.GetAccountAsync("acc-1")
.ReturnValue(Task.FromResult<AccountInfo?>(acc1));

var provExp = new IProviderServiceCreateExpectations();
provExp.Setups.ListCalendarsAsync("acc-1", Arg.Any<CancellationToken>())
.ReturnValue(Task.FromResult<IEnumerable<CalendarInfo>>(calendars));
provExp.Setups.GetCalendarEventsAsync(
"acc-1", Arg.Any<string?>(), Arg.Any<DateTime?>(), Arg.Any<DateTime?>(),
Arg.Any<int>(), Arg.Any<CancellationToken>())
.ReturnValue(Task.FromResult<IEnumerable<CalendarEvent>>([earlyEvent]));
.ReturnValue(Task.FromResult<IEnumerable<CalendarEvent>>(events));

var prov2Exp = new IProviderServiceCreateExpectations();
prov2Exp.Setups.GetCalendarEventsAsync(
"acc-2", Arg.Any<string?>(), Arg.Any<DateTime?>(), Arg.Any<DateTime?>(),
Arg.Any<int>(), Arg.Any<CancellationToken>())
.ReturnValue(Task.FromResult<IEnumerable<CalendarEvent>>([lateEvent]));
var provInstance = provExp.Instance();

var factExp = new IProviderServiceFactoryCreateExpectations();
factExp.Setups.GetProvider("microsoft365").ReturnValue(prov1Exp.Instance());
factExp.Setups.GetProvider("google").ReturnValue(prov2Exp.Instance());
// GetProvider is called twice: once for calendar lookup, once for fetching events
factExp.Setups.GetProvider("microsoft365").ReturnValue(provInstance).ExpectedCallCount(2);

var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
NullLogger<GetCalendarEventsTool>.Instance);

var result = await tool.GetCalendarEvents(TestTimeZone, Start, End);
var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, null, "cal-work");
var doc = JsonDocument.Parse(result);
var eventsArray = doc.RootElement.GetProperty("events");

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

regExp.Verify();
factExp.Verify();
prov1Exp.Verify();
prov2Exp.Verify();
provExp.Verify();
}

[TestMethod]
public async Task GetCalendarEvents_NoAccounts_ReturnsError()
public async Task GetCalendarEvents_NullAccountIdWithCalendarId_NoMatch_ReturnsError()
{
var acc1 = TestData.CreateAccount(id: "acc-1", provider: "microsoft365");
var calendars = new List<CalendarInfo> { TestData.CreateCalendar(id: "cal-other", accountId: "acc-1") };

var regExp = new IAccountRegistryCreateExpectations();
regExp.Setups.GetEnabledAccounts()
.ReturnValue([]);
regExp.Setups.GetEnabledAccounts().ReturnValue([acc1]);

var provExp = new IProviderServiceCreateExpectations();
provExp.Setups.ListCalendarsAsync("acc-1", Arg.Any<CancellationToken>())
.ReturnValue(Task.FromResult<IEnumerable<CalendarInfo>>(calendars));

var factExp = new IProviderServiceFactoryCreateExpectations();
factExp.Setups.GetProvider("microsoft365").ReturnValue(provExp.Instance());

var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
NullLogger<GetCalendarEventsTool>.Instance);

var result = await tool.GetCalendarEvents(TestTimeZone, Start, End);
var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, null, "cal-missing");
var doc = JsonDocument.Parse(result);

Assert.AreEqual("No accounts found", doc.RootElement.GetProperty("error").GetString());
Assert.IsTrue(doc.RootElement.GetProperty("error").GetString()!.Contains("No calendar found with id 'cal-missing'"));

regExp.Verify();
factExp.Verify();
provExp.Verify();
}

[TestMethod]
public async Task GetCalendarEvents_NullAccountIdWithCalendarId_AmbiguousCalendarId_ReturnsError()
{
var acc1 = TestData.CreateAccount(id: "acc-1", provider: "microsoft365");
var acc2 = TestData.CreateAccount(id: "acc-2", provider: "google");
var calendars1 = new List<CalendarInfo> { TestData.CreateCalendar(id: "cal-shared", accountId: "acc-1") };
var calendars2 = new List<CalendarInfo> { TestData.CreateCalendar(id: "cal-shared", accountId: "acc-2") };

var regExp = new IAccountRegistryCreateExpectations();
regExp.Setups.GetEnabledAccounts().ReturnValue([acc1, acc2]);

var prov1Exp = new IProviderServiceCreateExpectations();
prov1Exp.Setups.ListCalendarsAsync("acc-1", Arg.Any<CancellationToken>())
.ReturnValue(Task.FromResult<IEnumerable<CalendarInfo>>(calendars1));

var prov2Exp = new IProviderServiceCreateExpectations();
prov2Exp.Setups.ListCalendarsAsync("acc-2", Arg.Any<CancellationToken>())
.ReturnValue(Task.FromResult<IEnumerable<CalendarInfo>>(calendars2));

var factExp = new IProviderServiceFactoryCreateExpectations();
factExp.Setups.GetProvider("microsoft365").ReturnValue(prov1Exp.Instance());
factExp.Setups.GetProvider("google").ReturnValue(prov2Exp.Instance());

var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
NullLogger<GetCalendarEventsTool>.Instance);

var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, null, "cal-shared");
var doc = JsonDocument.Parse(result);

Assert.IsTrue(doc.RootElement.GetProperty("error").GetString()!.Contains("exists in multiple accounts"));

regExp.Verify();
factExp.Verify();
prov1Exp.Verify();
prov2Exp.Verify();
}
}