From 718e8b6c83f24bc8f371a91cc3cd028f3bcd5ffc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 03:20:30 +0000 Subject: [PATCH 1/3] Initial plan From 343fbeba2881852290fcbdfe1a5918a987093ee1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 03:25:53 +0000 Subject: [PATCH 2/3] feat: return validation error when get_calendar_events called without accountId Agent-Logs-Url: https://github.com/MarimerLLC/calendar-mcp/sessions/db8cffbd-bbfb-48fc-bb8d-67301641ab83 Co-authored-by: rockfordlhotka <2333134+rockfordlhotka@users.noreply.github.com> --- .../Tools/GetCalendarEventsTool.cs | 20 +++++--- .../Tools/GetCalendarEventsToolTests.cs | 51 +++---------------- 2 files changed, 18 insertions(+), 53 deletions(-) diff --git a/src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs b/src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs index 2f1b47a..3e092b4 100644 --- a/src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs +++ b/src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs @@ -17,12 +17,12 @@ public sealed class GetCalendarEventsTool( IProviderServiceFactory providerFactory, ILogger 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 and accountId parameters are 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.")] 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, or omit to query all accounts. Obtain from list_accounts.")] string? accountId = null, + [Description("Account ID to query. Required. 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("Maximum number of events to return per account (default 50)")] int count = 50) { @@ -35,6 +35,14 @@ public async Task GetCalendarEvents( }); } + if (string.IsNullOrEmpty(accountId)) + { + return JsonSerializer.Serialize(new + { + error = "accountId is required" + }); + } + var resolvedStart = startDate ?? TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz).Date; var resolvedEnd = endDate.HasValue ? endDate.Value.Date.AddDays(1) : resolvedStart.AddDays(7); @@ -43,18 +51,14 @@ public async Task GetCalendarEvents( try { - // Determine which accounts to query - var accounts = string.IsNullOrEmpty(accountId) - ? accountRegistry.GetEnabledAccounts() - : new[] { await accountRegistry.GetAccountAsync(accountId) }.Where(a => a != null).Cast(); - + var accounts = new[] { await accountRegistry.GetAccountAsync(accountId) }.Where(a => a != null).Cast(); 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" }); } diff --git a/src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs b/src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs index 124cd1b..c0bd2bd 100644 --- a/src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs +++ b/src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs @@ -102,69 +102,30 @@ 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 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 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>([earlyEvent])); - - var prov2Exp = new IProviderServiceCreateExpectations(); - prov2Exp.Setups.GetCalendarEventsAsync( - "acc-2", Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any()) - .ReturnValue(Task.FromResult>([lateEvent])); - 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 result = await tool.GetCalendarEvents(TestTimeZone, Start, End); + var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, null); var doc = JsonDocument.Parse(result); - var eventsArray = doc.RootElement.GetProperty("events"); - - Assert.AreEqual(2, 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(); + Assert.AreEqual("accountId is required", doc.RootElement.GetProperty("error").GetString()); } [TestMethod] - public async Task GetCalendarEvents_NoAccounts_ReturnsError() + public async Task GetCalendarEvents_EmptyAccountId_ReturnsValidationError() { var regExp = new IAccountRegistryCreateExpectations(); - regExp.Setups.GetEnabledAccounts() - .ReturnValue([]); - var factExp = new IProviderServiceFactoryCreateExpectations(); var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(), NullLogger.Instance); - var result = await tool.GetCalendarEvents(TestTimeZone, Start, End); + var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, ""); var doc = JsonDocument.Parse(result); - Assert.AreEqual("No accounts found", doc.RootElement.GetProperty("error").GetString()); - regExp.Verify(); + Assert.AreEqual("accountId is required", doc.RootElement.GetProperty("error").GetString()); } } From 1897f0a968058365c0c1d08900735215d3055784 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 03:41:50 +0000 Subject: [PATCH 3/3] feat: auto-resolve accountId from calendarId when calendarId uniquely identifies an account Agent-Logs-Url: https://github.com/MarimerLLC/calendar-mcp/sessions/32d8eb33-ca2d-4394-a875-d31098200681 Co-authored-by: rockfordlhotka <2333134+rockfordlhotka@users.noreply.github.com> --- .../Tools/GetCalendarEventsTool.cs | 66 +++++++++- .../Tools/GetCalendarEventsToolTests.cs | 113 ++++++++++++++++++ 2 files changed, 173 insertions(+), 6 deletions(-) diff --git a/src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs b/src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs index 3e092b4..0599b95 100644 --- a/src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs +++ b/src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs @@ -17,13 +17,13 @@ public sealed class GetCalendarEventsTool( IProviderServiceFactory providerFactory, ILogger logger) { - [McpServerTool, Description("Get calendar events for a date range from a specific account. The timeZone and accountId parameters are 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 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. Required. 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); @@ -37,10 +37,64 @@ public async Task GetCalendarEvents( if (string.IsNullOrEmpty(accountId)) { - return JsonSerializer.Serialize(new + if (string.IsNullOrEmpty(calendarId)) { - error = "accountId is required" - }); + 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().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; diff --git a/src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs b/src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs index c0bd2bd..003f94c 100644 --- a/src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs +++ b/src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs @@ -128,4 +128,117 @@ public async Task GetCalendarEvents_EmptyAccountId_ReturnsValidationError() 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 { TestData.CreateCalendar(id: "cal-work", accountId: "acc-1") }; + 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([acc1]); + regExp.Setups.GetAccountAsync("acc-1") + .ReturnValue(Task.FromResult(acc1)); + + var provExp = new IProviderServiceCreateExpectations(); + provExp.Setups.ListCalendarsAsync("acc-1", Arg.Any()) + .ReturnValue(Task.FromResult>(calendars)); + provExp.Setups.GetCalendarEventsAsync( + "acc-1", Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ReturnValue(Task.FromResult>(events)); + + var provInstance = provExp.Instance(); + + var factExp = new IProviderServiceFactoryCreateExpectations(); + // 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.Instance); + + var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, null, "cal-work"); + var doc = JsonDocument.Parse(result); + var eventsArray = doc.RootElement.GetProperty("events"); + + Assert.AreEqual(1, eventsArray.GetArrayLength()); + Assert.AreEqual("ev1", eventsArray[0].GetProperty("id").GetString()); + Assert.AreEqual(TestTimeZone, doc.RootElement.GetProperty("timezone").GetString()); + + regExp.Verify(); + factExp.Verify(); + provExp.Verify(); + } + + [TestMethod] + public async Task GetCalendarEvents_NullAccountIdWithCalendarId_NoMatch_ReturnsError() + { + var acc1 = TestData.CreateAccount(id: "acc-1", provider: "microsoft365"); + var calendars = new List { TestData.CreateCalendar(id: "cal-other", accountId: "acc-1") }; + + var regExp = new IAccountRegistryCreateExpectations(); + regExp.Setups.GetEnabledAccounts().ReturnValue([acc1]); + + var provExp = new IProviderServiceCreateExpectations(); + provExp.Setups.ListCalendarsAsync("acc-1", Arg.Any()) + .ReturnValue(Task.FromResult>(calendars)); + + 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, null, "cal-missing"); + var doc = JsonDocument.Parse(result); + + 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 { TestData.CreateCalendar(id: "cal-shared", accountId: "acc-1") }; + var calendars2 = new List { 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()) + .ReturnValue(Task.FromResult>(calendars1)); + + var prov2Exp = new IProviderServiceCreateExpectations(); + prov2Exp.Setups.ListCalendarsAsync("acc-2", Arg.Any()) + .ReturnValue(Task.FromResult>(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.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(); + } }