Skip to content

Commit f887820

Browse files
Skip non-calendar accounts in calendar reads (#62) (#64)
* Skip non-calendar accounts in calendar reads (#62) Email-only accounts (e.g. IMAP) have no calendar capability, so GetCalendarEventsAsync/ListCalendarsAsync throw NotSupportedException. During the all-accounts fan-out, get_calendar_events swallowed that into a generic "Failed to retrieve events from this account." warning, which appeared for every fan-out once #59 made all accounts the default. Centralize calendar-capability detection in a new AccountCapabilities helper (the logic previously inlined in ListAccountsTool) and use it to: - skip non-calendar accounts in the get_calendar_events and list_calendars fan-outs, and in the calendarId->account resolution lookup - return an actionable warning ("no calendar capability") instead of the generic error when an email-only accountId is targeted explicitly ListAccountsTool now delegates to the shared helper; its JSON output is unchanged. Bump version to 1.3.6. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Bump k8s deployment image to 1.3.6 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 02cf3b5 commit f887820

10 files changed

Lines changed: 345 additions & 72 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.5</VersionPrefix>
3+
<VersionPrefix>1.3.6</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.5
31+
image: docker.io/rockylhotka/calendar-mcp-http:1.3.6
3232
ports:
3333
- containerPort: 8080
3434
name: http
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
using CalendarMcp.Core.Models;
2+
3+
namespace CalendarMcp.Core.Services;
4+
5+
/// <summary>
6+
/// A single capability exposed by an account (e.g. <c>calendar</c>, <c>email</c>, <c>contacts</c>).
7+
/// </summary>
8+
public sealed record AccountCapability(string Name, bool ReadOnly);
9+
10+
/// <summary>
11+
/// Single source of truth for what an account can do, derived from its provider type
12+
/// (and, for the JSON provider, its configured file paths). Used by <c>list_accounts</c>
13+
/// to advertise capabilities and by the calendar tools to skip accounts that have no
14+
/// calendar capability (e.g. email-only IMAP accounts) instead of attempting a read that
15+
/// would throw <see cref="System.NotSupportedException"/> and surface a spurious warning.
16+
/// </summary>
17+
public static class AccountCapabilities
18+
{
19+
public const string Calendar = "calendar";
20+
public const string Email = "email";
21+
public const string Contacts = "contacts";
22+
23+
/// <summary>
24+
/// Determines the capabilities for an account based on its provider type and configuration.
25+
/// </summary>
26+
public static IReadOnlyList<AccountCapability> GetCapabilities(AccountInfo account)
27+
{
28+
var provider = account.Provider.ToLowerInvariant();
29+
30+
return provider switch
31+
{
32+
"microsoft365" or "m365" =>
33+
[
34+
new AccountCapability(Calendar, false),
35+
new AccountCapability(Email, false),
36+
new AccountCapability(Contacts, false)
37+
],
38+
"google" or "gmail" or "google workspace" =>
39+
[
40+
new AccountCapability(Calendar, false),
41+
new AccountCapability(Email, false),
42+
new AccountCapability(Contacts, false)
43+
],
44+
"outlook.com" or "outlook" or "hotmail" =>
45+
[
46+
new AccountCapability(Calendar, false),
47+
new AccountCapability(Email, false),
48+
new AccountCapability(Contacts, false)
49+
],
50+
"ics" or "icalendar" =>
51+
[
52+
new AccountCapability(Calendar, true)
53+
],
54+
"imap" or "imap-smtp" =>
55+
[
56+
new AccountCapability(Email, false)
57+
],
58+
"json" or "json-calendar" => GetJsonCapabilities(account),
59+
_ =>
60+
[
61+
new AccountCapability(Calendar, false)
62+
]
63+
};
64+
}
65+
66+
/// <summary>
67+
/// Returns <c>true</c> when the account advertises a calendar capability. False for
68+
/// email-only accounts (e.g. IMAP), letting calendar tools skip them during fan-out.
69+
/// </summary>
70+
public static bool HasCalendar(AccountInfo account) =>
71+
GetCapabilities(account).Any(c => c.Name == Calendar);
72+
73+
/// <summary>
74+
/// JSON accounts have optional email and contacts support depending on configured file paths.
75+
/// </summary>
76+
private static IReadOnlyList<AccountCapability> GetJsonCapabilities(AccountInfo account)
77+
{
78+
var config = account.ProviderConfig;
79+
var capabilities = new List<AccountCapability>
80+
{
81+
new(Calendar, true)
82+
};
83+
84+
if (config.ContainsKey("emailsFilePath") && !string.IsNullOrEmpty(config["emailsFilePath"])
85+
|| config.ContainsKey("emailsOneDrivePath") && !string.IsNullOrEmpty(config["emailsOneDrivePath"]))
86+
{
87+
capabilities.Add(new AccountCapability(Email, true));
88+
}
89+
90+
if (config.ContainsKey("contactsFilePath") && !string.IsNullOrEmpty(config["contactsFilePath"])
91+
|| config.ContainsKey("contactsOneDrivePath") && !string.IsNullOrEmpty(config["contactsOneDrivePath"]))
92+
{
93+
capabilities.Add(new AccountCapability(Contacts, true));
94+
}
95+
96+
return capabilities;
97+
}
98+
}

src/CalendarMcp.Core/Skills/accounts.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,17 @@ effectively required for any tool that writes data
7070

7171
The exceptions where omitting `accountId` is fine:
7272

73-
- `get_emails`, `search_emails`, `list_calendars`, `get_contacts`,
74-
`search_contacts` — these fan out across all enabled accounts when
75-
`accountId` is omitted, which is often what you want.
73+
- `get_emails`, `search_emails`, `list_calendars`, `get_calendar_events`,
74+
`get_contacts`, `search_contacts` — these fan out across all enabled
75+
accounts when `accountId` is omitted, which is often what you want.
7676
- `get_contextual_email_summary` — always fans out across all accounts.
7777

78+
The calendar fan-outs (`list_calendars`, `get_calendar_events`)
79+
automatically skip accounts that lack a `calendar` capability (e.g.
80+
email-only IMAP accounts), so those never produce errors or warnings.
81+
Targeting such an account explicitly via `accountId` returns no events
82+
plus an actionable warning.
83+
7884
## Capability checking before write operations
7985

8086
Before calling a write tool (e.g. `create_event`), confirm the chosen

src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,11 @@ public async Task<string> GetCalendarEvents(
5353
// calendarId provided but no accountId — try to resolve the account automatically
5454
try
5555
{
56-
var allAccounts = accountRegistry.GetEnabledAccounts().ToList();
56+
// Only calendar-capable accounts can own a calendar; skip email-only accounts
57+
// (e.g. IMAP) so their NotSupportedException doesn't pollute the lookup.
58+
var allAccounts = accountRegistry.GetEnabledAccounts()
59+
.Where(AccountCapabilities.HasCalendar)
60+
.ToList();
5761
var lookupTasks = allAccounts.Select(async acc =>
5862
{
5963
try
@@ -97,6 +101,32 @@ public async Task<string> GetCalendarEvents(
97101
throw new McpException("No accounts found");
98102
}
99103

104+
// Email-only accounts (e.g. IMAP) have no calendar capability, so a read would throw
105+
// NotSupportedException and surface a misleading "Failed to retrieve events" warning.
106+
// - explicit accountId targeting such an account -> actionable warning, no fetch
107+
// - all-accounts fan-out -> silently skip them
108+
var warnings = new List<object>();
109+
if (!string.IsNullOrEmpty(accountId))
110+
{
111+
var only = validAccounts[0];
112+
if (!AccountCapabilities.HasCalendar(only))
113+
{
114+
logger.LogInformation("Account {AccountId} has no calendar capability; returning empty result", only.Id);
115+
warnings.Add(new
116+
{
117+
accountId = only.Id,
118+
warning = $"Account '{only.Id}' has no calendar capability (it is email-only). Use list_accounts to see each account's capabilities."
119+
});
120+
validAccounts = new List<AccountInfo>();
121+
}
122+
}
123+
else
124+
{
125+
foreach (var skipped in validAccounts.Where(a => !AccountCapabilities.HasCalendar(a)))
126+
logger.LogInformation("Skipping account {AccountId} in calendar read: no calendar capability", skipped.Id);
127+
validAccounts = validAccounts.Where(AccountCapabilities.HasCalendar).ToList();
128+
}
129+
100130
var resolvedStart = startDate ?? TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz).Date;
101131
var resolvedEnd = endDate.HasValue ? endDate.Value.Date.AddDays(1) : resolvedStart.AddDays(7);
102132

@@ -106,7 +136,6 @@ public async Task<string> GetCalendarEvents(
106136
try
107137
{
108138
// Query all accounts in parallel
109-
var warnings = new List<object>();
110139
var tasks = validAccounts.Select(async account =>
111140
{
112141
try
Lines changed: 2 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using System.ComponentModel;
22
using System.Text.Json;
3-
using CalendarMcp.Core.Models;
43
using CalendarMcp.Core.Services;
54
using Microsoft.Extensions.Logging;
65
using ModelContextProtocol;
@@ -33,7 +32,8 @@ public async Task<string> ListAccounts()
3332
provider = a.Provider,
3433
displayName = a.DisplayName,
3534
domains = a.Domains,
36-
capabilities = GetAccountCapabilities(a)
35+
capabilities = AccountCapabilities.GetCapabilities(a)
36+
.Select(c => new { name = c.Name, readOnly = c.ReadOnly })
3737
})
3838
};
3939

@@ -48,67 +48,4 @@ public async Task<string> ListAccounts()
4848
throw new McpException("Failed to list accounts.", ex);
4949
}
5050
}
51-
52-
/// <summary>
53-
/// Determines capabilities for an account based on its provider type and configuration.
54-
/// </summary>
55-
private static List<object> GetAccountCapabilities(AccountInfo account)
56-
{
57-
var provider = account.Provider.ToLowerInvariant();
58-
59-
return provider switch
60-
{
61-
"microsoft365" or "m365" => [
62-
new { name = "calendar", readOnly = false },
63-
new { name = "email", readOnly = false },
64-
new { name = "contacts", readOnly = false }
65-
],
66-
"google" or "gmail" or "google workspace" => [
67-
new { name = "calendar", readOnly = false },
68-
new { name = "email", readOnly = false },
69-
new { name = "contacts", readOnly = false }
70-
],
71-
"outlook.com" or "outlook" or "hotmail" => [
72-
new { name = "calendar", readOnly = false },
73-
new { name = "email", readOnly = false },
74-
new { name = "contacts", readOnly = false }
75-
],
76-
"ics" or "icalendar" => [
77-
new { name = "calendar", readOnly = true }
78-
],
79-
"imap" or "imap-smtp" => [
80-
new { name = "email", readOnly = false }
81-
],
82-
"json" or "json-calendar" => GetJsonCapabilities(account),
83-
_ => [
84-
new { name = "calendar", readOnly = false }
85-
]
86-
};
87-
}
88-
89-
/// <summary>
90-
/// JSON accounts have optional email and contacts support depending on configured file paths.
91-
/// </summary>
92-
private static List<object> GetJsonCapabilities(AccountInfo account)
93-
{
94-
var config = account.ProviderConfig;
95-
var capabilities = new List<object>
96-
{
97-
new { name = "calendar", readOnly = true }
98-
};
99-
100-
if (config.ContainsKey("emailsFilePath") && !string.IsNullOrEmpty(config["emailsFilePath"])
101-
|| config.ContainsKey("emailsOneDrivePath") && !string.IsNullOrEmpty(config["emailsOneDrivePath"]))
102-
{
103-
capabilities.Add(new { name = "email", readOnly = true });
104-
}
105-
106-
if (config.ContainsKey("contactsFilePath") && !string.IsNullOrEmpty(config["contactsFilePath"])
107-
|| config.ContainsKey("contactsOneDrivePath") && !string.IsNullOrEmpty(config["contactsOneDrivePath"]))
108-
{
109-
capabilities.Add(new { name = "contacts", readOnly = true });
110-
}
111-
112-
return capabilities;
113-
}
11451
}

src/CalendarMcp.Core/Tools/ListCalendarsTool.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ public async Task<string> ListCalendars(
3030
validAccounts = accountRegistry.GetEnabledAccounts().ToList();
3131
if (validAccounts.Count == 0)
3232
throw new McpException("No accounts found");
33+
34+
// Skip email-only accounts (e.g. IMAP) — listing calendars on them would throw
35+
// NotSupportedException. An explicit accountId is left untouched so a direct
36+
// request still surfaces the provider error.
37+
foreach (var skipped in validAccounts.Where(a => !AccountCapabilities.HasCalendar(a)))
38+
logger.LogInformation("Skipping account {AccountId} in list_calendars: no calendar capability", skipped.Id);
39+
validAccounts = validAccounts.Where(AccountCapabilities.HasCalendar).ToList();
3340
}
3441
else
3542
{
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using CalendarMcp.Core.Services;
2+
using CalendarMcp.Tests.Helpers;
3+
4+
namespace CalendarMcp.Tests.Services;
5+
6+
[TestClass]
7+
public class AccountCapabilitiesTests
8+
{
9+
[DataTestMethod]
10+
[DataRow("microsoft365")]
11+
[DataRow("m365")]
12+
[DataRow("google")]
13+
[DataRow("gmail")]
14+
[DataRow("google workspace")]
15+
[DataRow("outlook.com")]
16+
[DataRow("outlook")]
17+
[DataRow("hotmail")]
18+
[DataRow("ics")]
19+
[DataRow("icalendar")]
20+
[DataRow("json")]
21+
[DataRow("json-calendar")]
22+
[DataRow("some-unknown-provider")]
23+
public void HasCalendar_CalendarCapableProviders_ReturnsTrue(string provider)
24+
{
25+
var account = TestData.CreateAccount(provider: provider);
26+
Assert.IsTrue(AccountCapabilities.HasCalendar(account));
27+
}
28+
29+
[DataTestMethod]
30+
[DataRow("imap")]
31+
[DataRow("imap-smtp")]
32+
public void HasCalendar_EmailOnlyProviders_ReturnsFalse(string provider)
33+
{
34+
var account = TestData.CreateAccount(provider: provider);
35+
Assert.IsFalse(AccountCapabilities.HasCalendar(account));
36+
}
37+
38+
[TestMethod]
39+
public void HasCalendar_IsCaseInsensitive()
40+
{
41+
Assert.IsFalse(AccountCapabilities.HasCalendar(TestData.CreateAccount(provider: "IMAP")));
42+
Assert.IsTrue(AccountCapabilities.HasCalendar(TestData.CreateAccount(provider: "Microsoft365")));
43+
}
44+
45+
[TestMethod]
46+
public void GetCapabilities_Imap_IsEmailOnly()
47+
{
48+
var caps = AccountCapabilities.GetCapabilities(TestData.CreateAccount(provider: "imap"));
49+
CollectionAssert.AreEquivalent(
50+
new[] { AccountCapabilities.Email },
51+
caps.Select(c => c.Name).ToArray());
52+
}
53+
54+
[TestMethod]
55+
public void GetCapabilities_Ics_IsReadOnlyCalendar()
56+
{
57+
var caps = AccountCapabilities.GetCapabilities(TestData.CreateAccount(provider: "ics"));
58+
var calendar = caps.Single(c => c.Name == AccountCapabilities.Calendar);
59+
Assert.IsTrue(calendar.ReadOnly);
60+
Assert.AreEqual(1, caps.Count);
61+
}
62+
63+
[TestMethod]
64+
public void GetCapabilities_Json_AddsEmailAndContactsWhenPathsConfigured()
65+
{
66+
var account = TestData.CreateAccount(provider: "json", providerConfig: new Dictionary<string, string>
67+
{
68+
["emailsFilePath"] = "emails.json",
69+
["contactsFilePath"] = "contacts.json"
70+
});
71+
72+
var names = AccountCapabilities.GetCapabilities(account).Select(c => c.Name).ToArray();
73+
CollectionAssert.AreEquivalent(
74+
new[] { AccountCapabilities.Calendar, AccountCapabilities.Email, AccountCapabilities.Contacts },
75+
names);
76+
}
77+
78+
[TestMethod]
79+
public void GetCapabilities_Json_CalendarOnlyWhenNoExtraPaths()
80+
{
81+
var account = TestData.CreateAccount(provider: "json", providerConfig: new Dictionary<string, string>());
82+
var names = AccountCapabilities.GetCapabilities(account).Select(c => c.Name).ToArray();
83+
CollectionAssert.AreEquivalent(new[] { AccountCapabilities.Calendar }, names);
84+
}
85+
}

0 commit comments

Comments
 (0)