diff --git a/Directory.Build.props b/Directory.Build.props index a76fdc6..e9bbf38 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 1.3.3 + 1.3.4 diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 54ab8f1..d147fd8 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -28,7 +28,7 @@ spec: fsGroup: 1000 containers: - name: calendar-mcp - image: docker.io/rockylhotka/calendar-mcp-http:latest + image: docker.io/rockylhotka/calendar-mcp-http:1.3.4 ports: - containerPort: 8080 name: http diff --git a/src/CalendarMcp.Core/Skills/calendar.md b/src/CalendarMcp.Core/Skills/calendar.md index c16c0f2..8a09eb0 100644 --- a/src/CalendarMcp.Core/Skills/calendar.md +++ b/src/CalendarMcp.Core/Skills/calendar.md @@ -33,8 +33,9 @@ used. output and how `startDate`/`endDate` are interpreted. - `startDate` defaults to today (in `timeZone`); `endDate` defaults to 7 days after `startDate`. -- `accountId` is required unless `calendarId` uniquely identifies one - account (the server resolves it). +- `accountId` fans out across all enabled accounts when omitted (like + `list_calendars`). Provide it to scope to one account, or provide + `calendarId` alone to resolve the account when it uniquely identifies one. - `count` is per-account. Returns events sorted by start time, each with `id, accountId, diff --git a/src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs b/src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs index 150c89d..53b8122 100644 --- a/src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs +++ b/src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs @@ -18,12 +18,12 @@ public sealed class GetCalendarEventsTool( IProviderServiceFactory providerFactory, ILogger logger) { - [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.")] + [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.")] public async Task 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. Obtain from list_accounts. Required unless calendarId uniquely identifies a single account.")] string? accountId = 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("Maximum number of events to return per account (default 50)")] int count = 50) { @@ -31,11 +31,17 @@ public async Task GetCalendarEvents( if (tz == null) throw new McpException($"Invalid IANA timezone: '{timeZone}'. Use a valid IANA timezone name such as 'America/Chicago', 'Europe/London', or 'Asia/Tokyo'."); - if (string.IsNullOrEmpty(accountId)) + // Determine which accounts to query (mirrors search_emails / list_calendars): + // - accountId provided -> that single account + // - calendarId provided alone -> resolve the owning account automatically + // - neither provided -> all enabled accounts + List validAccounts; + if (!string.IsNullOrEmpty(accountId)) + { + validAccounts = new List { await ToolGuard.RequireAccountAsync(accountRegistry, accountId) }; + } + else if (!string.IsNullOrEmpty(calendarId)) { - if (string.IsNullOrEmpty(calendarId)) - throw new McpException("accountId is required"); - // calendarId provided but no accountId — try to resolve the account automatically try { @@ -72,19 +78,25 @@ public async Task GetCalendarEvents( logger.LogError(ex, "Error resolving accountId from calendarId {CalendarId}", calendarId); throw new McpException("Failed to resolve account from calendarId.", ex); } + + validAccounts = new List { await ToolGuard.RequireAccountAsync(accountRegistry, accountId) }; + } + else + { + // Neither accountId nor calendarId supplied — query all enabled accounts. + validAccounts = accountRegistry.GetEnabledAccounts().ToList(); + if (validAccounts.Count == 0) + throw new McpException("No accounts found"); } var resolvedStart = startDate ?? TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz).Date; var resolvedEnd = endDate.HasValue ? endDate.Value.Date.AddDays(1) : resolvedStart.AddDays(7); - logger.LogInformation("Getting calendar events: startDate={StartDate}, endDate={EndDate}, accountId={AccountId}, count={Count}, timeZone={TimeZone}", - resolvedStart, resolvedEnd, accountId, count, timeZone); + logger.LogInformation("Getting calendar events: startDate={StartDate}, endDate={EndDate}, accountCount={AccountCount}, count={Count}, timeZone={TimeZone}", + resolvedStart, resolvedEnd, validAccounts.Count, count, timeZone); try { - var account = await ToolGuard.RequireAccountAsync(accountRegistry, accountId); - var validAccounts = new List { account }; - // Query all accounts in parallel var warnings = new List(); var tasks = validAccounts.Select(async account => diff --git a/src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs b/src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs index 41cc752..3761a23 100644 --- a/src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs +++ b/src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs @@ -101,29 +101,112 @@ public async Task GetCalendarEvents_SpecificAccount_ReturnsEventsWithTimezone() } [TestMethod] - public async Task GetCalendarEvents_NullAccountId_ThrowsMcpException() + public async Task GetCalendarEvents_NullAccountId_QueriesAllEnabledAccounts() { + var acc1 = TestData.CreateAccount(id: "acc-1", provider: "microsoft365"); + var acc2 = TestData.CreateAccount(id: "acc-2", provider: "google"); + var events1 = new List + { + TestData.CreateEvent(id: "ev1", accountId: "acc-1", subject: "M365 Meeting", + start: new DateTime(2025, 1, 10, 15, 0, 0, DateTimeKind.Utc), + end: new DateTime(2025, 1, 10, 16, 0, 0, DateTimeKind.Utc)) + }; + var events2 = new List + { + TestData.CreateEvent(id: "ev2", accountId: "acc-2", subject: "Google Meeting", + start: new DateTime(2025, 1, 9, 15, 0, 0, DateTimeKind.Utc), + end: new DateTime(2025, 1, 9, 16, 0, 0, DateTimeKind.Utc)) + }; + var regExp = new IAccountRegistryCreateExpectations(); + regExp.Setups.GetEnabledAccounts().ReturnValue([acc1, acc2]); + + var prov1Exp = new IProviderServiceCreateExpectations(); + prov1Exp.Setups.GetCalendarEventsAsync( + "acc-1", Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ReturnValue(Task.FromResult>(events1)); + + var prov2Exp = new IProviderServiceCreateExpectations(); + prov2Exp.Setups.GetCalendarEventsAsync( + "acc-2", Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ReturnValue(Task.FromResult>(events2)); + 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.Instance); - var ex = await Assert.ThrowsExactlyAsync( - () => tool.GetCalendarEvents(TestTimeZone, Start, End, null)); - Assert.AreEqual("accountId is required", ex.Message); + var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, null); + var doc = JsonDocument.Parse(result); + var eventsArray = doc.RootElement.GetProperty("events"); + + // Events from both accounts are merged and sorted by start time (ev2 is earlier). + Assert.AreEqual(2, eventsArray.GetArrayLength()); + Assert.AreEqual("ev2", eventsArray[0].GetProperty("id").GetString()); + Assert.AreEqual("ev1", eventsArray[1].GetProperty("id").GetString()); + + regExp.Verify(); + factExp.Verify(); + prov1Exp.Verify(); + prov2Exp.Verify(); } [TestMethod] - public async Task GetCalendarEvents_EmptyAccountId_ThrowsMcpException() + public async Task GetCalendarEvents_EmptyAccountId_QueriesAllEnabledAccounts() + { + var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365"); + var events = new List + { + 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([account]); + + var provExp = new IProviderServiceCreateExpectations(); + provExp.Setups.GetCalendarEventsAsync( + "acc-1", Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ReturnValue(Task.FromResult>(events)); + + var factExp = new IProviderServiceFactoryCreateExpectations(); + factExp.Setups.GetProvider("microsoft365").ReturnValue(provExp.Instance()); + + var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(), + NullLogger.Instance); + + var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, ""); + var doc = JsonDocument.Parse(result); + var eventsArray = doc.RootElement.GetProperty("events"); + + Assert.AreEqual(1, eventsArray.GetArrayLength()); + Assert.AreEqual("ev1", eventsArray[0].GetProperty("id").GetString()); + + regExp.Verify(); + factExp.Verify(); + provExp.Verify(); + } + + [TestMethod] + public async Task GetCalendarEvents_NoAccountsConfigured_ThrowsMcpException() { var regExp = new IAccountRegistryCreateExpectations(); + regExp.Setups.GetEnabledAccounts().ReturnValue([]); + var factExp = new IProviderServiceFactoryCreateExpectations(); var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(), NullLogger.Instance); var ex = await Assert.ThrowsExactlyAsync( - () => tool.GetCalendarEvents(TestTimeZone, Start, End, "")); - Assert.AreEqual("accountId is required", ex.Message); + () => tool.GetCalendarEvents(TestTimeZone, Start, End, null)); + Assert.AreEqual("No accounts found", ex.Message); + regExp.Verify(); } [TestMethod]