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.3</VersionPrefix>
<VersionPrefix>1.3.4</VersionPrefix>
</PropertyGroup>
</Project>
2 changes: 1 addition & 1 deletion k8s/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/CalendarMcp.Core/Skills/calendar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 23 additions & 11 deletions src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,30 @@ public sealed class GetCalendarEventsTool(
IProviderServiceFactory providerFactory,
ILogger<GetCalendarEventsTool> 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<string> 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)
{
var tz = TimeZoneHelper.TryGetTimeZone(timeZone);
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<AccountInfo> validAccounts;
if (!string.IsNullOrEmpty(accountId))
{
validAccounts = new List<AccountInfo> { 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
{
Expand Down Expand Up @@ -72,19 +78,25 @@ public async Task<string> GetCalendarEvents(
logger.LogError(ex, "Error resolving accountId from calendarId {CalendarId}", calendarId);
throw new McpException("Failed to resolve account from calendarId.", ex);
}

validAccounts = new List<AccountInfo> { 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<AccountInfo> { account };

// Query all accounts in parallel
var warnings = new List<object>();
var tasks = validAccounts.Select(async account =>
Expand Down
97 changes: 90 additions & 7 deletions src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CalendarEvent>
{
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<CalendarEvent>
{
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<string?>(), Arg.Any<DateTime?>(), Arg.Any<DateTime?>(),
Arg.Any<int>(), Arg.Any<CancellationToken>())
.ReturnValue(Task.FromResult<IEnumerable<CalendarEvent>>(events1));

var prov2Exp = new IProviderServiceCreateExpectations();
prov2Exp.Setups.GetCalendarEventsAsync(
"acc-2", Arg.Any<string?>(), Arg.Any<DateTime?>(), Arg.Any<DateTime?>(),
Arg.Any<int>(), Arg.Any<CancellationToken>())
.ReturnValue(Task.FromResult<IEnumerable<CalendarEvent>>(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<GetCalendarEventsTool>.Instance);

var ex = await Assert.ThrowsExactlyAsync<McpException>(
() => 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<CalendarEvent>
{
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<string?>(), 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("microsoft365").ReturnValue(provExp.Instance());

var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
NullLogger<GetCalendarEventsTool>.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<GetCalendarEventsTool>.Instance);

var ex = await Assert.ThrowsExactlyAsync<McpException>(
() => 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]
Expand Down
Loading