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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<Project>
<PropertyGroup>
<VersionPrefix>1.3.6</VersionPrefix>
<VersionPrefix>1.3.7</VersionPrefix>
</PropertyGroup>
</Project>
6 changes: 4 additions & 2 deletions src/CalendarMcp.Core/Providers/M365ProviderService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -607,8 +607,10 @@ public async Task<IEnumerable<CalendarEvent>> GetCalendarEventsAsync(

// Use CalendarView for date range queries (it expands recurring events)
Microsoft.Graph.Models.EventCollectionResponse? events;

if (string.IsNullOrEmpty(calendarId))

// "primary" is our alias for the default calendar (Graph has no such id), so treat
// it like an omitted calendarId — mirrors GetCalendarEventDetailsAsync below.
if (string.IsNullOrEmpty(calendarId) || calendarId == "primary")
{
// Query the default calendar
events = await graphClient.Me.Calendar.CalendarView.GetAsync(config =>
Expand Down
6 changes: 4 additions & 2 deletions src/CalendarMcp.Core/Providers/OutlookComProviderService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -590,8 +590,10 @@ public async Task<IEnumerable<CalendarEvent>> GetCalendarEventsAsync(
var end = endDate ?? DateTime.UtcNow.Date.AddDays(30);

Microsoft.Graph.Models.EventCollectionResponse? events;

if (string.IsNullOrEmpty(calendarId))

// "primary" is our alias for the default calendar (Graph has no such id), so treat
// it like an omitted calendarId — mirrors GetCalendarEventDetailsAsync below.
if (string.IsNullOrEmpty(calendarId) || calendarId == "primary")
{
events = await graphClient.Me.Calendar.CalendarView.GetAsync(config =>
{
Expand Down
17 changes: 15 additions & 2 deletions src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public async Task<string> GetCalendarEvents(
[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 enabled accounts. Obtain from list_accounts.")] 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("Calendar ID to query, or omit for all calendars. Obtain from list_calendars, or pass 'primary' for the account's default calendar (also the value returned for default-calendar events). Requires accountId when using 'primary'. 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 @@ -46,7 +46,12 @@ public async Task<string> GetCalendarEvents(
if (!string.IsNullOrEmpty(accountId))
{
validAccounts = new List<AccountInfo> { await ToolGuard.RequireAccountAsync(accountRegistry, accountId) };
validateCalendarId = !string.IsNullOrEmpty(calendarId);
// "primary" is a universal alias for each account's default calendar. It never
// appears in ListCalendarsAsync output (providers return real calendar ids), and
// it's what the tool emits as the calendarId for default-calendar events — so skip
// validation and let the provider resolve it. Validating it would wrongly warn
// "not found" and skip the fetch.
validateCalendarId = !string.IsNullOrEmpty(calendarId) && !IsPrimaryAlias(calendarId);
}
else if (!string.IsNullOrEmpty(calendarId))
{
Expand Down Expand Up @@ -224,4 +229,12 @@ public async Task<string> GetCalendarEvents(
throw new McpException("Failed to get calendar events.", ex);
}
}

/// <summary>
/// "primary" is the alias every provider uses for an account's default calendar, and the
/// calendarId the tool emits for default-calendar events. It is accepted as input but is
/// never returned by ListCalendarsAsync, so it must bypass calendarId validation.
/// </summary>
private static bool IsPrimaryAlias(string? calendarId) =>
string.Equals(calendarId, "primary", StringComparison.Ordinal);
}
51 changes: 49 additions & 2 deletions src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,9 @@ public async Task GetCalendarEvents_AccountIdWithMissingCalendarId_WarnsAndRetur
var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
NullLogger<GetCalendarEventsTool>.Instance);

var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, "acc-1", "primary");
// Use a genuinely non-existent id here ("primary" is a default-calendar alias that is
// intentionally accepted without validation — see the primary-alias test).
var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, "acc-1", "cal-missing");
var doc = JsonDocument.Parse(result);

Assert.AreEqual(0, doc.RootElement.GetProperty("events").GetArrayLength());
Expand All @@ -239,7 +241,7 @@ public async Task GetCalendarEvents_AccountIdWithMissingCalendarId_WarnsAndRetur
Assert.AreEqual(1, warnings.GetArrayLength());
Assert.AreEqual("acc-1", warnings[0].GetProperty("accountId").GetString());
var warningText = warnings[0].GetProperty("warning").GetString();
Assert.IsTrue(warningText!.Contains("primary"));
Assert.IsTrue(warningText!.Contains("cal-missing"));
Assert.IsTrue(warningText.Contains("acc-1"));
Assert.IsTrue(warningText.Contains("list_calendars"));

Expand All @@ -248,6 +250,51 @@ public async Task GetCalendarEvents_AccountIdWithMissingCalendarId_WarnsAndRetur
provExp.Verify();
}

[TestMethod]
public async Task GetCalendarEvents_AccountIdWithPrimaryAlias_SkipsValidationAndReturnsEvents()
{
// "primary" is the default-calendar alias and is never returned by ListCalendarsAsync,
// so it must bypass calendarId validation rather than warn "not found". The provider
// receives "primary" and resolves it to the default calendar.
var account = TestData.CreateAccount(id: "rockyl", provider: "outlook.com");
var events = new List<CalendarEvent>
{
TestData.CreateEvent(id: "ev1", accountId: "rockyl", calendarId: "primary", 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.GetAccountAsync("rockyl")
.ReturnValue(Task.FromResult<AccountInfo?>(account));

var provExp = new IProviderServiceCreateExpectations();
// ListCalendarsAsync must NOT be called for the "primary" alias — no validation needed.
provExp.Setups.GetCalendarEventsAsync(
"rockyl", "primary", Arg.Any<DateTime?>(), Arg.Any<DateTime?>(),
Arg.Any<int>(), Arg.Any<CancellationToken>())
.ReturnValue(Task.FromResult<IEnumerable<CalendarEvent>>(events));

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

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

var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, "rockyl", "primary");
var doc = JsonDocument.Parse(result);

Assert.AreEqual(1, doc.RootElement.GetProperty("events").GetArrayLength());
Assert.AreEqual("ev1", doc.RootElement.GetProperty("events")[0].GetProperty("id").GetString());
Assert.AreEqual("primary", doc.RootElement.GetProperty("events")[0].GetProperty("calendarId").GetString());
// No "not found" warning for the primary alias.
Assert.AreEqual(JsonValueKind.Null, doc.RootElement.GetProperty("warnings").ValueKind);

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

[TestMethod]
public async Task GetCalendarEvents_AccountIdWithValidCalendarId_ReturnsEventsNoWarning()
{
Expand Down
Loading