Skip to content

Commit 02cf3b5

Browse files
Warn when a provided calendarId is not found in the target account (#61) (#63)
* Warn when a provided calendarId is not found in the target account (#61) When get_calendar_events is called with an explicit accountId and a calendarId that doesn't exist in that account (e.g. calendarId="primary" against an Outlook/Graph account), the provider returns an empty list with no signal. The caller can't distinguish "calendar exists but is empty" from "calendarId doesn't exist here", so it reads as a silent failure. This validates the calendarId against ListCalendarsAsync in the explicit accountId + calendarId path and adds a per-account entry to the existing warnings channel when no calendar matches, skipping the pointless events fetch. It stays a warning (empty-but-valid calendars are legitimate) and a ListCalendarsAsync failure falls through to the normal read rather than blocking it. The calendarId-only auto-resolve path already validates via its account lookup, so a flag avoids a redundant ListCalendarsAsync call there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Bump version to 1.3.5 and update k8s image tag for testing 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 7bbfa44 commit 02cf3b5

4 files changed

Lines changed: 121 additions & 2 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.4</VersionPrefix>
3+
<VersionPrefix>1.3.5</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:1.3.4
31+
image: docker.io/rockylhotka/calendar-mcp-http:1.3.5
3232
ports:
3333
- containerPort: 8080
3434
name: http

src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,18 @@ public async Task<string> GetCalendarEvents(
3535
// - accountId provided -> that single account
3636
// - calendarId provided alone -> resolve the owning account automatically
3737
// - neither provided -> all enabled accounts
38+
// When an explicit accountId is paired with a calendarId, we can't tell from an empty
39+
// result whether the calendar is genuinely empty or the calendarId simply doesn't exist
40+
// in that account. Validate it against ListCalendarsAsync and surface a warning if missing.
41+
// The calendarId-only path (below) already validates via its account-resolution lookup,
42+
// so this flag stays false there to avoid a redundant ListCalendarsAsync call.
43+
var validateCalendarId = false;
44+
3845
List<AccountInfo> validAccounts;
3946
if (!string.IsNullOrEmpty(accountId))
4047
{
4148
validAccounts = new List<AccountInfo> { await ToolGuard.RequireAccountAsync(accountRegistry, accountId) };
49+
validateCalendarId = !string.IsNullOrEmpty(calendarId);
4250
}
4351
else if (!string.IsNullOrEmpty(calendarId))
4452
{
@@ -104,6 +112,34 @@ public async Task<string> GetCalendarEvents(
104112
try
105113
{
106114
var provider = providerFactory.GetProvider(account!.Provider);
115+
116+
if (validateCalendarId)
117+
{
118+
// Best-effort check that the supplied calendarId actually exists in this
119+
// account. If it doesn't, warn and skip the (pointless) events fetch.
120+
// A ListCalendarsAsync failure shouldn't block the read, so we fall through.
121+
try
122+
{
123+
var calendars = await provider.ListCalendarsAsync(account.Id, CancellationToken.None);
124+
if (!calendars.Any(c => c.Id == calendarId))
125+
{
126+
lock (warnings)
127+
{
128+
warnings.Add(new
129+
{
130+
accountId = account.Id,
131+
warning = $"calendarId '{calendarId}' was not found in account '{account.Id}'. Use list_calendars to obtain a valid calendar id."
132+
});
133+
}
134+
return Enumerable.Empty<CalendarEvent>();
135+
}
136+
}
137+
catch (Exception ex)
138+
{
139+
logger.LogWarning(ex, "Error validating calendarId {CalendarId} for account {AccountId}", calendarId, account.Id);
140+
}
141+
}
142+
107143
var events = await provider.GetCalendarEventsAsync(
108144
account.Id, calendarId, resolvedStart, resolvedEnd, count, CancellationToken.None);
109145
return events;

src/CalendarMcp.Tests/Tools/GetCalendarEventsToolTests.cs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,89 @@ public async Task GetCalendarEvents_NoAccountsConfigured_ThrowsMcpException()
209209
regExp.Verify();
210210
}
211211

212+
[TestMethod]
213+
public async Task GetCalendarEvents_AccountIdWithMissingCalendarId_WarnsAndReturnsEmpty()
214+
{
215+
var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365");
216+
var calendars = new List<CalendarInfo> { TestData.CreateCalendar(id: "cal-real", accountId: "acc-1") };
217+
218+
var regExp = new IAccountRegistryCreateExpectations();
219+
regExp.Setups.GetAccountAsync("acc-1")
220+
.ReturnValue(Task.FromResult<AccountInfo?>(account));
221+
222+
var provExp = new IProviderServiceCreateExpectations();
223+
provExp.Setups.ListCalendarsAsync("acc-1", Arg.Any<CancellationToken>())
224+
.ReturnValue(Task.FromResult<IEnumerable<CalendarInfo>>(calendars));
225+
// GetCalendarEventsAsync must NOT be called when the calendarId doesn't exist.
226+
227+
var factExp = new IProviderServiceFactoryCreateExpectations();
228+
factExp.Setups.GetProvider("microsoft365").ReturnValue(provExp.Instance());
229+
230+
var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
231+
NullLogger<GetCalendarEventsTool>.Instance);
232+
233+
var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, "acc-1", "primary");
234+
var doc = JsonDocument.Parse(result);
235+
236+
Assert.AreEqual(0, doc.RootElement.GetProperty("events").GetArrayLength());
237+
238+
var warnings = doc.RootElement.GetProperty("warnings");
239+
Assert.AreEqual(1, warnings.GetArrayLength());
240+
Assert.AreEqual("acc-1", warnings[0].GetProperty("accountId").GetString());
241+
var warningText = warnings[0].GetProperty("warning").GetString();
242+
Assert.IsTrue(warningText!.Contains("primary"));
243+
Assert.IsTrue(warningText.Contains("acc-1"));
244+
Assert.IsTrue(warningText.Contains("list_calendars"));
245+
246+
regExp.Verify();
247+
factExp.Verify();
248+
provExp.Verify();
249+
}
250+
251+
[TestMethod]
252+
public async Task GetCalendarEvents_AccountIdWithValidCalendarId_ReturnsEventsNoWarning()
253+
{
254+
var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365");
255+
var calendars = new List<CalendarInfo> { TestData.CreateCalendar(id: "cal-work", accountId: "acc-1") };
256+
var events = new List<CalendarEvent>
257+
{
258+
TestData.CreateEvent(id: "ev1", accountId: "acc-1", subject: "Meeting",
259+
start: new DateTime(2025, 1, 10, 15, 0, 0, DateTimeKind.Utc),
260+
end: new DateTime(2025, 1, 10, 16, 0, 0, DateTimeKind.Utc))
261+
};
262+
263+
var regExp = new IAccountRegistryCreateExpectations();
264+
regExp.Setups.GetAccountAsync("acc-1")
265+
.ReturnValue(Task.FromResult<AccountInfo?>(account));
266+
267+
var provExp = new IProviderServiceCreateExpectations();
268+
provExp.Setups.ListCalendarsAsync("acc-1", Arg.Any<CancellationToken>())
269+
.ReturnValue(Task.FromResult<IEnumerable<CalendarInfo>>(calendars));
270+
provExp.Setups.GetCalendarEventsAsync(
271+
"acc-1", Arg.Any<string?>(), Arg.Any<DateTime?>(), Arg.Any<DateTime?>(),
272+
Arg.Any<int>(), Arg.Any<CancellationToken>())
273+
.ReturnValue(Task.FromResult<IEnumerable<CalendarEvent>>(events));
274+
275+
var factExp = new IProviderServiceFactoryCreateExpectations();
276+
// The provider is resolved once per account and reused for both the calendarId
277+
// validation and the events fetch.
278+
factExp.Setups.GetProvider("microsoft365").ReturnValue(provExp.Instance());
279+
280+
var tool = new GetCalendarEventsTool(regExp.Instance(), factExp.Instance(),
281+
NullLogger<GetCalendarEventsTool>.Instance);
282+
283+
var result = await tool.GetCalendarEvents(TestTimeZone, Start, End, "acc-1", "cal-work");
284+
var doc = JsonDocument.Parse(result);
285+
286+
Assert.AreEqual(1, doc.RootElement.GetProperty("events").GetArrayLength());
287+
Assert.AreEqual("ev1", doc.RootElement.GetProperty("events")[0].GetProperty("id").GetString());
288+
Assert.AreEqual(JsonValueKind.Null, doc.RootElement.GetProperty("warnings").ValueKind);
289+
290+
regExp.Verify();
291+
factExp.Verify();
292+
provExp.Verify();
293+
}
294+
212295
[TestMethod]
213296
public async Task GetCalendarEvents_NullAccountIdWithCalendarId_SingleMatch_ResolvesAccount()
214297
{

0 commit comments

Comments
 (0)