Skip to content

Commit 7bbfa44

Browse files
Default get_calendar_events to all enabled accounts (#59) (#60)
* Default get_calendar_events to all enabled accounts (#59) get_calendar_events threw "accountId is required" when both accountId and calendarId were omitted, even though accountId is schema-optional and sibling read tools (search_emails, list_calendars, get_emails, get_contacts) default to querying all accounts. This inconsistency read as a bug and was a top tool-call failure for consuming agents. When neither accountId nor calendarId is supplied, query all enabled accounts instead of throwing, mirroring list_calendars. The existing parallel fan-out and per-account warning aggregation already looped over validAccounts, so accounts that don't support calendar degrade into a warning. Bumps version to 1.3.4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pin k8s deployment image to 1.3.4 Replace the floating :latest tag with the explicit 1.3.4 tag so the deployed version is unambiguous and matches the image pushed for #59. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Update calendar guide: get_calendar_events defaults to all accounts The GetGuide calendar.md skill still said accountId was required unless calendarId resolved one account. Align it with the #59 behavior change: omitting accountId now fans out across all enabled accounts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e4b5ba0 commit 7bbfa44

5 files changed

Lines changed: 118 additions & 22 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.3</VersionPrefix>
3+
<VersionPrefix>1.3.4</VersionPrefix>
44
</PropertyGroup>
55
</Project>

k8s/deployment.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ spec:
2828
fsGroup: 1000
2929
containers:
3030
- name: calendar-mcp
31-
image: docker.io/rockylhotka/calendar-mcp-http:latest
31+
image: docker.io/rockylhotka/calendar-mcp-http:1.3.4
3232
ports:
3333
- containerPort: 8080
3434
name: http

src/CalendarMcp.Core/Skills/calendar.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ used.
3333
output and how `startDate`/`endDate` are interpreted.
3434
- `startDate` defaults to today (in `timeZone`); `endDate` defaults to
3535
7 days after `startDate`.
36-
- `accountId` is required unless `calendarId` uniquely identifies one
37-
account (the server resolves it).
36+
- `accountId` fans out across all enabled accounts when omitted (like
37+
`list_calendars`). Provide it to scope to one account, or provide
38+
`calendarId` alone to resolve the account when it uniquely identifies one.
3839
- `count` is per-account.
3940

4041
Returns events sorted by start time, each with `id, accountId,

src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,30 @@ public sealed class GetCalendarEventsTool(
1818
IProviderServiceFactory providerFactory,
1919
ILogger<GetCalendarEventsTool> logger)
2020
{
21-
[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.")]
21+
[McpServerTool, Description("Get calendar events for a date range from one or all accounts. The timeZone parameter is required. Omit accountId (and calendarId) to query all enabled accounts at once; provide accountId to scope to one account, or provide calendarId alone to resolve the account automatically when it uniquely identifies a single account. 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.")]
2222
public async Task<string> GetCalendarEvents(
2323
[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,
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,
26-
[Description("Account ID to query. Obtain from list_accounts. Required unless calendarId uniquely identifies a single account.")] string? accountId = null,
26+
[Description("Account ID to query, or omit to query all enabled accounts. Obtain from list_accounts.")] string? accountId = null,
2727
[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,
2828
[Description("Maximum number of events to return per account (default 50)")] int count = 50)
2929
{
3030
var tz = TimeZoneHelper.TryGetTimeZone(timeZone);
3131
if (tz == null)
3232
throw new McpException($"Invalid IANA timezone: '{timeZone}'. Use a valid IANA timezone name such as 'America/Chicago', 'Europe/London', or 'Asia/Tokyo'.");
3333

34-
if (string.IsNullOrEmpty(accountId))
34+
// Determine which accounts to query (mirrors search_emails / list_calendars):
35+
// - accountId provided -> that single account
36+
// - calendarId provided alone -> resolve the owning account automatically
37+
// - neither provided -> all enabled accounts
38+
List<AccountInfo> validAccounts;
39+
if (!string.IsNullOrEmpty(accountId))
40+
{
41+
validAccounts = new List<AccountInfo> { await ToolGuard.RequireAccountAsync(accountRegistry, accountId) };
42+
}
43+
else if (!string.IsNullOrEmpty(calendarId))
3544
{
36-
if (string.IsNullOrEmpty(calendarId))
37-
throw new McpException("accountId is required");
38-
3945
// calendarId provided but no accountId — try to resolve the account automatically
4046
try
4147
{
@@ -72,19 +78,25 @@ public async Task<string> GetCalendarEvents(
7278
logger.LogError(ex, "Error resolving accountId from calendarId {CalendarId}", calendarId);
7379
throw new McpException("Failed to resolve account from calendarId.", ex);
7480
}
81+
82+
validAccounts = new List<AccountInfo> { await ToolGuard.RequireAccountAsync(accountRegistry, accountId) };
83+
}
84+
else
85+
{
86+
// Neither accountId nor calendarId supplied — query all enabled accounts.
87+
validAccounts = accountRegistry.GetEnabledAccounts().ToList();
88+
if (validAccounts.Count == 0)
89+
throw new McpException("No accounts found");
7590
}
7691

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

80-
logger.LogInformation("Getting calendar events: startDate={StartDate}, endDate={EndDate}, accountId={AccountId}, count={Count}, timeZone={TimeZone}",
81-
resolvedStart, resolvedEnd, accountId, count, timeZone);
95+
logger.LogInformation("Getting calendar events: startDate={StartDate}, endDate={EndDate}, accountCount={AccountCount}, count={Count}, timeZone={TimeZone}",
96+
resolvedStart, resolvedEnd, validAccounts.Count, count, timeZone);
8297

8398
try
8499
{
85-
var account = await ToolGuard.RequireAccountAsync(accountRegistry, accountId);
86-
var validAccounts = new List<AccountInfo> { account };
87-
88100
// Query all accounts in parallel
89101
var warnings = new List<object>();
90102
var tasks = validAccounts.Select(async account =>

src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,29 +101,112 @@ public async Task GetCalendarEvents_SpecificAccount_ReturnsEventsWithTimezone()
101101
}
102102

103103
[TestMethod]
104-
public async Task GetCalendarEvents_NullAccountId_ThrowsMcpException()
104+
public async Task GetCalendarEvents_NullAccountId_QueriesAllEnabledAccounts()
105105
{
106+
var acc1 = TestData.CreateAccount(id: "acc-1", provider: "microsoft365");
107+
var acc2 = TestData.CreateAccount(id: "acc-2", provider: "google");
108+
var events1 = new List<CalendarEvent>
109+
{
110+
TestData.CreateEvent(id: "ev1", accountId: "acc-1", subject: "M365 Meeting",
111+
start: new DateTime(2025, 1, 10, 15, 0, 0, DateTimeKind.Utc),
112+
end: new DateTime(2025, 1, 10, 16, 0, 0, DateTimeKind.Utc))
113+
};
114+
var events2 = new List<CalendarEvent>
115+
{
116+
TestData.CreateEvent(id: "ev2", accountId: "acc-2", subject: "Google Meeting",
117+
start: new DateTime(2025, 1, 9, 15, 0, 0, DateTimeKind.Utc),
118+
end: new DateTime(2025, 1, 9, 16, 0, 0, DateTimeKind.Utc))
119+
};
120+
106121
var regExp = new IAccountRegistryCreateExpectations();
122+
regExp.Setups.GetEnabledAccounts().ReturnValue([acc1, acc2]);
123+
124+
var prov1Exp = new IProviderServiceCreateExpectations();
125+
prov1Exp.Setups.GetCalendarEventsAsync(
126+
"acc-1", Arg.Any<string?>(), Arg.Any<DateTime?>(), Arg.Any<DateTime?>(),
127+
Arg.Any<int>(), Arg.Any<CancellationToken>())
128+
.ReturnValue(Task.FromResult<IEnumerable<CalendarEvent>>(events1));
129+
130+
var prov2Exp = new IProviderServiceCreateExpectations();
131+
prov2Exp.Setups.GetCalendarEventsAsync(
132+
"acc-2", Arg.Any<string?>(), Arg.Any<DateTime?>(), Arg.Any<DateTime?>(),
133+
Arg.Any<int>(), Arg.Any<CancellationToken>())
134+
.ReturnValue(Task.FromResult<IEnumerable<CalendarEvent>>(events2));
135+
107136
var factExp = new IProviderServiceFactoryCreateExpectations();
137+
factExp.Setups.GetProvider("microsoft365").ReturnValue(prov1Exp.Instance());
138+
factExp.Setups.GetProvider("google").ReturnValue(prov2Exp.Instance());
139+
108140
var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
109141
NullLogger<GetCalendarEventsTool>.Instance);
110142

111-
var ex = await Assert.ThrowsExactlyAsync<McpException>(
112-
() => tool.GetCalendarEvents(TestTimeZone, Start, End, null));
113-
Assert.AreEqual("accountId is required", ex.Message);
143+
var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, null);
144+
var doc = JsonDocument.Parse(result);
145+
var eventsArray = doc.RootElement.GetProperty("events");
146+
147+
// Events from both accounts are merged and sorted by start time (ev2 is earlier).
148+
Assert.AreEqual(2, eventsArray.GetArrayLength());
149+
Assert.AreEqual("ev2", eventsArray[0].GetProperty("id").GetString());
150+
Assert.AreEqual("ev1", eventsArray[1].GetProperty("id").GetString());
151+
152+
regExp.Verify();
153+
factExp.Verify();
154+
prov1Exp.Verify();
155+
prov2Exp.Verify();
114156
}
115157

116158
[TestMethod]
117-
public async Task GetCalendarEvents_EmptyAccountId_ThrowsMcpException()
159+
public async Task GetCalendarEvents_EmptyAccountId_QueriesAllEnabledAccounts()
160+
{
161+
var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365");
162+
var events = new List<CalendarEvent>
163+
{
164+
TestData.CreateEvent(id: "ev1", accountId: "acc-1", subject: "Meeting",
165+
start: new DateTime(2025, 1, 10, 15, 0, 0, DateTimeKind.Utc),
166+
end: new DateTime(2025, 1, 10, 16, 0, 0, DateTimeKind.Utc))
167+
};
168+
169+
var regExp = new IAccountRegistryCreateExpectations();
170+
regExp.Setups.GetEnabledAccounts().ReturnValue([account]);
171+
172+
var provExp = new IProviderServiceCreateExpectations();
173+
provExp.Setups.GetCalendarEventsAsync(
174+
"acc-1", Arg.Any<string?>(), Arg.Any<DateTime?>(), Arg.Any<DateTime?>(),
175+
Arg.Any<int>(), Arg.Any<CancellationToken>())
176+
.ReturnValue(Task.FromResult<IEnumerable<CalendarEvent>>(events));
177+
178+
var factExp = new IProviderServiceFactoryCreateExpectations();
179+
factExp.Setups.GetProvider("microsoft365").ReturnValue(provExp.Instance());
180+
181+
var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
182+
NullLogger<GetCalendarEventsTool>.Instance);
183+
184+
var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, "");
185+
var doc = JsonDocument.Parse(result);
186+
var eventsArray = doc.RootElement.GetProperty("events");
187+
188+
Assert.AreEqual(1, eventsArray.GetArrayLength());
189+
Assert.AreEqual("ev1", eventsArray[0].GetProperty("id").GetString());
190+
191+
regExp.Verify();
192+
factExp.Verify();
193+
provExp.Verify();
194+
}
195+
196+
[TestMethod]
197+
public async Task GetCalendarEvents_NoAccountsConfigured_ThrowsMcpException()
118198
{
119199
var regExp = new IAccountRegistryCreateExpectations();
200+
regExp.Setups.GetEnabledAccounts().ReturnValue([]);
201+
120202
var factExp = new IProviderServiceFactoryCreateExpectations();
121203
var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
122204
NullLogger<GetCalendarEventsTool>.Instance);
123205

124206
var ex = await Assert.ThrowsExactlyAsync<McpException>(
125-
() => tool.GetCalendarEvents(TestTimeZone, Start, End, ""));
126-
Assert.AreEqual("accountId is required", ex.Message);
207+
() => tool.GetCalendarEvents(TestTimeZone, Start, End, null));
208+
Assert.AreEqual("No accounts found", ex.Message);
209+
regExp.Verify();
127210
}
128211

129212
[TestMethod]

0 commit comments

Comments
 (0)