Skip to content

Commit 6aec47f

Browse files
Add account capabilities and fix JSON email deserialization (#45)
* Add per-account capabilities to list_accounts tool response LLM consumers had no way to know which accounts support email, contacts, or just calendar. JSON accounts with email/contacts file paths configured were not being used for those features because the capability was never advertised. Each account now reports its capabilities (calendar, email, contacts) with a readOnly flag based on provider type and configuration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix JSON email deserialization and enable dual Serilog+OTEL logging The email JSON file uses plain strings for body, from, toRecipients, and ccRecipients fields rather than the Graph API object format. Added custom JsonConverters (JsonBodyConverter, JsonRecipientConverter, JsonRecipientListConverter) that handle both string and object formats for these fields, allowing the JSON provider to load email data correctly. Also changed HTTP server logging to always use Serilog file logging, adding OTEL on top when an endpoint is configured, rather than either/or. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 94118ff commit 6aec47f

3 files changed

Lines changed: 187 additions & 25 deletions

File tree

src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,15 +1072,19 @@ internal class JsonEmailEntry
10721072
public string? Subject { get; set; }
10731073

10741074
[JsonPropertyName("from")]
1075+
[JsonConverter(typeof(JsonRecipientConverter))]
10751076
public JsonRecipient? From { get; set; }
10761077

10771078
[JsonPropertyName("toRecipients")]
1079+
[JsonConverter(typeof(JsonRecipientListConverter))]
10781080
public List<JsonRecipient>? ToRecipients { get; set; }
10791081

10801082
[JsonPropertyName("ccRecipients")]
1083+
[JsonConverter(typeof(JsonRecipientListConverter))]
10811084
public List<JsonRecipient>? CcRecipients { get; set; }
10821085

10831086
[JsonPropertyName("body")]
1087+
[JsonConverter(typeof(JsonBodyConverter))]
10841088
public JsonBody? Body { get; set; }
10851089

10861090
[JsonPropertyName("bodyPreview")]
@@ -1110,3 +1114,106 @@ internal class JsonBody
11101114
[JsonPropertyName("contentType")]
11111115
public string? ContentType { get; set; }
11121116
}
1117+
1118+
/// <summary>
1119+
/// Handles both string ("body": "text") and object ("body": {"content":"...","contentType":"..."}) formats.
1120+
/// </summary>
1121+
internal class JsonBodyConverter : JsonConverter<JsonBody?>
1122+
{
1123+
public override JsonBody? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
1124+
{
1125+
if (reader.TokenType == JsonTokenType.Null)
1126+
return null;
1127+
1128+
if (reader.TokenType == JsonTokenType.String)
1129+
return new JsonBody { Content = reader.GetString(), ContentType = "text" };
1130+
1131+
if (reader.TokenType == JsonTokenType.StartObject)
1132+
return JsonSerializer.Deserialize<JsonBody>(ref reader);
1133+
1134+
reader.Skip();
1135+
return null;
1136+
}
1137+
1138+
public override void Write(Utf8JsonWriter writer, JsonBody? value, JsonSerializerOptions options)
1139+
=> JsonSerializer.Serialize(writer, value, options);
1140+
}
1141+
1142+
/// <summary>
1143+
/// Handles recipient lists that may contain strings, objects, or be a single string.
1144+
/// Supports: ["a@b.com"], [{"emailAddress":{"address":"a@b.com"}}], "a@b.com", or mixed arrays.
1145+
/// </summary>
1146+
internal class JsonRecipientListConverter : JsonConverter<List<JsonRecipient>?>
1147+
{
1148+
public override List<JsonRecipient>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
1149+
{
1150+
if (reader.TokenType == JsonTokenType.Null)
1151+
return null;
1152+
1153+
if (reader.TokenType == JsonTokenType.String)
1154+
{
1155+
var value = reader.GetString();
1156+
return string.IsNullOrEmpty(value) ? [] :
1157+
[new JsonRecipient { EmailAddress = new JsonEmailAddress { Address = value, Name = value } }];
1158+
}
1159+
1160+
if (reader.TokenType == JsonTokenType.StartArray)
1161+
{
1162+
var list = new List<JsonRecipient>();
1163+
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
1164+
{
1165+
if (reader.TokenType == JsonTokenType.String)
1166+
{
1167+
var addr = reader.GetString();
1168+
list.Add(new JsonRecipient { EmailAddress = new JsonEmailAddress { Address = addr, Name = addr } });
1169+
}
1170+
else if (reader.TokenType == JsonTokenType.StartObject)
1171+
{
1172+
var recipient = JsonSerializer.Deserialize<JsonRecipient>(ref reader);
1173+
if (recipient != null) list.Add(recipient);
1174+
}
1175+
else
1176+
{
1177+
reader.Skip();
1178+
}
1179+
}
1180+
return list;
1181+
}
1182+
1183+
reader.Skip();
1184+
return null;
1185+
}
1186+
1187+
public override void Write(Utf8JsonWriter writer, List<JsonRecipient>? value, JsonSerializerOptions options)
1188+
=> JsonSerializer.Serialize(writer, value, options);
1189+
}
1190+
1191+
/// <summary>
1192+
/// Handles both string ("from": "email@example.com") and object ("from": {"emailAddress":{"address":"..."}}) formats.
1193+
/// </summary>
1194+
internal class JsonRecipientConverter : JsonConverter<JsonRecipient?>
1195+
{
1196+
public override JsonRecipient? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
1197+
{
1198+
if (reader.TokenType == JsonTokenType.Null)
1199+
return null;
1200+
1201+
if (reader.TokenType == JsonTokenType.String)
1202+
{
1203+
var value = reader.GetString();
1204+
return new JsonRecipient
1205+
{
1206+
EmailAddress = new JsonEmailAddress { Address = value, Name = value }
1207+
};
1208+
}
1209+
1210+
if (reader.TokenType == JsonTokenType.StartObject)
1211+
return JsonSerializer.Deserialize<JsonRecipient>(ref reader);
1212+
1213+
reader.Skip();
1214+
return null;
1215+
}
1216+
1217+
public override void Write(Utf8JsonWriter writer, JsonRecipient? value, JsonSerializerOptions options)
1218+
=> JsonSerializer.Serialize(writer, value, options);
1219+
}

src/CalendarMcp.Core/Tools/ListAccountsTool.cs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.ComponentModel;
22
using System.Text.Json;
3+
using CalendarMcp.Core.Models;
34
using CalendarMcp.Core.Services;
45
using Microsoft.Extensions.Logging;
56
using ModelContextProtocol.Server;
@@ -14,7 +15,7 @@ public sealed class ListAccountsTool(
1415
IAccountRegistry accountRegistry,
1516
ILogger<ListAccountsTool> logger)
1617
{
17-
[McpServerTool, Description("List all configured email and calendar accounts. Returns accountId, provider, displayName, and domains for each. Use the accountId values when calling other tools to scope operations to a specific account.")]
18+
[McpServerTool, Description("List all configured accounts with their capabilities. Returns accountId, provider, displayName, domains, and capabilities (calendar, email, contacts) for each. Use the accountId values when calling other tools to scope operations to a specific account.")]
1819
public async Task<string> ListAccounts()
1920
{
2021
logger.LogInformation("Listing all accounts");
@@ -30,7 +31,8 @@ public async Task<string> ListAccounts()
3031
accountId = a.Id,
3132
provider = a.Provider,
3233
displayName = a.DisplayName,
33-
domains = a.Domains
34+
domains = a.Domains,
35+
capabilities = GetAccountCapabilities(a)
3436
})
3537
};
3638

@@ -49,4 +51,64 @@ public async Task<string> ListAccounts()
4951
});
5052
}
5153
}
54+
55+
/// <summary>
56+
/// Determines capabilities for an account based on its provider type and configuration.
57+
/// </summary>
58+
private static List<object> GetAccountCapabilities(AccountInfo account)
59+
{
60+
var provider = account.Provider.ToLowerInvariant();
61+
62+
return provider switch
63+
{
64+
"microsoft365" or "m365" => [
65+
new { name = "calendar", readOnly = false },
66+
new { name = "email", readOnly = false },
67+
new { name = "contacts", readOnly = false }
68+
],
69+
"google" or "gmail" or "google workspace" => [
70+
new { name = "calendar", readOnly = false },
71+
new { name = "email", readOnly = false },
72+
new { name = "contacts", readOnly = false }
73+
],
74+
"outlook.com" or "outlook" or "hotmail" => [
75+
new { name = "calendar", readOnly = false },
76+
new { name = "email", readOnly = false },
77+
new { name = "contacts", readOnly = false }
78+
],
79+
"ics" or "icalendar" => [
80+
new { name = "calendar", readOnly = true }
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+
}
52114
}

src/CalendarMcp.HttpServer/Program.cs

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,20 @@ public static void Main(string[] args)
2929

3030
var otlpEndpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT");
3131

32-
// If no OTLP endpoint, use Serilog for file + console logging
33-
if (string.IsNullOrEmpty(otlpEndpoint))
34-
{
35-
Log.Logger = new LoggerConfiguration()
36-
.MinimumLevel.Information()
37-
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
38-
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
39-
.Enrich.FromLogContext()
40-
.WriteTo.Console(
41-
outputTemplate: "{Timestamp:HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
42-
.WriteTo.File(
43-
path: Path.Combine(logDir, "calendar-mcp-http-.log"),
44-
rollingInterval: RollingInterval.Day,
45-
retainedFileCountLimit: 7,
46-
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
47-
.CreateLogger();
48-
}
32+
// Always configure Serilog for file logging
33+
Log.Logger = new LoggerConfiguration()
34+
.MinimumLevel.Information()
35+
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
36+
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
37+
.Enrich.FromLogContext()
38+
.WriteTo.Console(
39+
outputTemplate: "{Timestamp:HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
40+
.WriteTo.File(
41+
path: Path.Combine(logDir, "calendar-mcp-http-.log"),
42+
rollingInterval: RollingInterval.Day,
43+
retainedFileCountLimit: 7,
44+
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
45+
.CreateLogger();
4946

5047
Log.Information("Calendar MCP HTTP Server starting. Config directory: {ConfigDir}", configDir);
5148

@@ -78,10 +75,10 @@ public static void Main(string[] args)
7875
// Add environment variables (can override file settings)
7976
builder.Configuration.AddEnvironmentVariables("CALENDAR_MCP_");
8077

81-
// Configure logging
78+
// Configure logging - always use Serilog, add OTEL if endpoint is available
79+
builder.Host.UseSerilog();
8280
if (!string.IsNullOrEmpty(otlpEndpoint))
8381
{
84-
builder.Logging.ClearProviders();
8582
builder.Logging.AddOpenTelemetry(options =>
8683
{
8784
options.SetResourceBuilder(ResourceBuilder.CreateDefault()
@@ -91,10 +88,6 @@ public static void Main(string[] args)
9188
options.IncludeScopes = true;
9289
});
9390
}
94-
else
95-
{
96-
builder.Host.UseSerilog();
97-
}
9891

9992
// Configure Calendar MCP settings
10093
builder.Services.Configure<CalendarMcpConfiguration>(

0 commit comments

Comments
 (0)