Skip to content

Commit a7c104b

Browse files
Accept "primary" as a default-calendar alias on input (#66)
get_calendar_events emits calendarId "primary" for default-calendar events, but the input side rejected it: with accountId + calendarId both set, the tool validated calendarId against ListCalendarsAsync (which never lists "primary"), warned "not found", and skipped the fetch. Even past that, the Outlook.com/M365 providers only routed an empty calendarId to the default calendar, so "primary" hit Me.Calendars["primary"] and returned nothing. Close the symmetry gap: - GetCalendarEventsTool skips calendarId validation for the "primary" alias and passes it through. - Outlook.com and M365 GetCalendarEventsAsync treat "primary" as the default calendar (mirrors their existing GetCalendarEventDetailsAsync). Google already resolves "primary" natively; ICS/JSON ignore calendarId. Also document "primary" in the calendarId parameter description and bump version to 1.3.7. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c222f5f commit a7c104b

5 files changed

Lines changed: 73 additions & 9 deletions

File tree

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<Project>
22
<PropertyGroup>
3-
<VersionPrefix>1.3.6</VersionPrefix>
3+
<VersionPrefix>1.3.7</VersionPrefix>
44
</PropertyGroup>
55
</Project>

src/CalendarMcp.Core/Providers/M365ProviderService.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -607,8 +607,10 @@ public async Task<IEnumerable<CalendarEvent>> GetCalendarEventsAsync(
607607

608608
// Use CalendarView for date range queries (it expands recurring events)
609609
Microsoft.Graph.Models.EventCollectionResponse? events;
610-
611-
if (string.IsNullOrEmpty(calendarId))
610+
611+
// "primary" is our alias for the default calendar (Graph has no such id), so treat
612+
// it like an omitted calendarId — mirrors GetCalendarEventDetailsAsync below.
613+
if (string.IsNullOrEmpty(calendarId) || calendarId == "primary")
612614
{
613615
// Query the default calendar
614616
events = await graphClient.Me.Calendar.CalendarView.GetAsync(config =>

src/CalendarMcp.Core/Providers/OutlookComProviderService.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -590,8 +590,10 @@ public async Task<IEnumerable<CalendarEvent>> GetCalendarEventsAsync(
590590
var end = endDate ?? DateTime.UtcNow.Date.AddDays(30);
591591

592592
Microsoft.Graph.Models.EventCollectionResponse? events;
593-
594-
if (string.IsNullOrEmpty(calendarId))
593+
594+
// "primary" is our alias for the default calendar (Graph has no such id), so treat
595+
// it like an omitted calendarId — mirrors GetCalendarEventDetailsAsync below.
596+
if (string.IsNullOrEmpty(calendarId) || calendarId == "primary")
595597
{
596598
events = await graphClient.Me.Calendar.CalendarView.GetAsync(config =>
597599
{

src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public async Task<string> GetCalendarEvents(
2424
[Description("Start of the date range (ISO 8601 format, e.g. `2026-02-20`). Defaults to today.")] DateTime? startDate = null,
2525
[Description("End of the date range, inclusive (ISO 8601 format, e.g. `2026-02-27`). Defaults to 7 days after startDate.")] DateTime? endDate = null,
2626
[Description("Account ID to query, or omit to query all enabled accounts. Obtain from list_accounts.")] string? accountId = null,
27-
[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,
27+
[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,
2828
[Description("Maximum number of events to return per account (default 50)")] int count = 50)
2929
{
3030
var tz = TimeZoneHelper.TryGetTimeZone(timeZone);
@@ -46,7 +46,12 @@ public async Task<string> GetCalendarEvents(
4646
if (!string.IsNullOrEmpty(accountId))
4747
{
4848
validAccounts = new List<AccountInfo> { await ToolGuard.RequireAccountAsync(accountRegistry, accountId) };
49-
validateCalendarId = !string.IsNullOrEmpty(calendarId);
49+
// "primary" is a universal alias for each account's default calendar. It never
50+
// appears in ListCalendarsAsync output (providers return real calendar ids), and
51+
// it's what the tool emits as the calendarId for default-calendar events — so skip
52+
// validation and let the provider resolve it. Validating it would wrongly warn
53+
// "not found" and skip the fetch.
54+
validateCalendarId = !string.IsNullOrEmpty(calendarId) && !IsPrimaryAlias(calendarId);
5055
}
5156
else if (!string.IsNullOrEmpty(calendarId))
5257
{
@@ -224,4 +229,12 @@ public async Task<string> GetCalendarEvents(
224229
throw new McpException("Failed to get calendar events.", ex);
225230
}
226231
}
232+
233+
/// <summary>
234+
/// "primary" is the alias every provider uses for an account's default calendar, and the
235+
/// calendarId the tool emits for default-calendar events. It is accepted as input but is
236+
/// never returned by ListCalendarsAsync, so it must bypass calendarId validation.
237+
/// </summary>
238+
private static bool IsPrimaryAlias(string? calendarId) =>
239+
string.Equals(calendarId, "primary", StringComparison.Ordinal);
227240
}

src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,9 @@ public async Task GetCalendarEvents_AccountIdWithMissingCalendarId_WarnsAndRetur
230230
var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
231231
NullLogger<GetCalendarEventsTool>.Instance);
232232

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

236238
Assert.AreEqual(0, doc.RootElement.GetProperty("events").GetArrayLength());
@@ -239,7 +241,7 @@ public async Task GetCalendarEvents_AccountIdWithMissingCalendarId_WarnsAndRetur
239241
Assert.AreEqual(1, warnings.GetArrayLength());
240242
Assert.AreEqual("acc-1", warnings[0].GetProperty("accountId").GetString());
241243
var warningText = warnings[0].GetProperty("warning").GetString();
242-
Assert.IsTrue(warningText!.Contains("primary"));
244+
Assert.IsTrue(warningText!.Contains("cal-missing"));
243245
Assert.IsTrue(warningText.Contains("acc-1"));
244246
Assert.IsTrue(warningText.Contains("list_calendars"));
245247

@@ -248,6 +250,51 @@ public async Task GetCalendarEvents_AccountIdWithMissingCalendarId_WarnsAndRetur
248250
provExp.Verify();
249251
}
250252

253+
[TestMethod]
254+
public async Task GetCalendarEvents_AccountIdWithPrimaryAlias_SkipsValidationAndReturnsEvents()
255+
{
256+
// "primary" is the default-calendar alias and is never returned by ListCalendarsAsync,
257+
// so it must bypass calendarId validation rather than warn "not found". The provider
258+
// receives "primary" and resolves it to the default calendar.
259+
var account = TestData.CreateAccount(id: "rockyl", provider: "outlook.com");
260+
var events = new List<CalendarEvent>
261+
{
262+
TestData.CreateEvent(id: "ev1", accountId: "rockyl", calendarId: "primary", subject: "Meeting",
263+
start: new DateTime(2025, 1, 10, 15, 0, 0, DateTimeKind.Utc),
264+
end: new DateTime(2025, 1, 10, 16, 0, 0, DateTimeKind.Utc))
265+
};
266+
267+
var regExp = new IAccountRegistryCreateExpectations();
268+
regExp.Setups.GetAccountAsync("rockyl")
269+
.ReturnValue(Task.FromResult<AccountInfo?>(account));
270+
271+
var provExp = new IProviderServiceCreateExpectations();
272+
// ListCalendarsAsync must NOT be called for the "primary" alias — no validation needed.
273+
provExp.Setups.GetCalendarEventsAsync(
274+
"rockyl", "primary", Arg.Any<DateTime?>(), Arg.Any<DateTime?>(),
275+
Arg.Any<int>(), Arg.Any<CancellationToken>())
276+
.ReturnValue(Task.FromResult<IEnumerable<CalendarEvent>>(events));
277+
278+
var factExp = new IProviderServiceFactoryCreateExpectations();
279+
factExp.Setups.GetProvider("outlook.com").ReturnValue(provExp.Instance());
280+
281+
var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
282+
NullLogger<GetCalendarEventsTool>.Instance);
283+
284+
var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, "rockyl", "primary");
285+
var doc = JsonDocument.Parse(result);
286+
287+
Assert.AreEqual(1, doc.RootElement.GetProperty("events").GetArrayLength());
288+
Assert.AreEqual("ev1", doc.RootElement.GetProperty("events")[0].GetProperty("id").GetString());
289+
Assert.AreEqual("primary", doc.RootElement.GetProperty("events")[0].GetProperty("calendarId").GetString());
290+
// No "not found" warning for the primary alias.
291+
Assert.AreEqual(JsonValueKind.Null, doc.RootElement.GetProperty("warnings").ValueKind);
292+
293+
regExp.Verify();
294+
factExp.Verify();
295+
provExp.Verify();
296+
}
297+
251298
[TestMethod]
252299
public async Task GetCalendarEvents_AccountIdWithValidCalendarId_ReturnsEventsNoWarning()
253300
{

0 commit comments

Comments
 (0)