From 5674459e53985d2924b634baec0723afe2f07539 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 22:56:46 +0000 Subject: [PATCH 1/8] Add file attachment support to send_email tool Attachments are passed as a JSON array of objects with name, contentType, and base64Content fields so they round-trip cleanly through any MCP client (Claude Desktop, rockbot, etc.) without filesystem access on the server. Adds an EmailAttachment model and threads it through IProviderService and all six provider implementations: M365 and Outlook.com use Graph FileAttachment (capped at 3 MB per attachment by the SendMail endpoint); Google and IMAP/SMTP build MIME via MimeKit BodyBuilder; ICS and JSON file providers continue to throw NotSupportedException. The tool itself caps total payload at 25 MB and validates name/content/size before forwarding. https://claude.ai/code/session_01ETP8QfdTvDo9xBaDz5mbP8 --- .../Models/EmailAttachment.cs | 26 +++++ .../Providers/GoogleProviderService.cs | 68 +++++++---- .../Providers/GraphAttachmentBuilder.cs | 55 +++++++++ .../Providers/IcsProviderService.cs | 1 + .../Providers/ImapProviderService.cs | 26 ++++- .../Providers/JsonCalendarProviderService.cs | 1 + .../Providers/M365ProviderService.cs | 20 ++-- .../Providers/MimeAttachmentBuilder.cs | 39 +++++++ .../Providers/OutlookComProviderService.cs | 20 ++-- .../Services/IProviderService.cs | 1 + src/CalendarMcp.Core/Tools/SendEmailTool.cs | 49 +++++++- .../Tools/UnsubscribeFromEmailTool.cs | 2 +- .../Tools/SendEmailToolTests.cs | 106 +++++++++++++++++- 13 files changed, 368 insertions(+), 46 deletions(-) create mode 100644 src/CalendarMcp.Core/Models/EmailAttachment.cs create mode 100644 src/CalendarMcp.Core/Providers/GraphAttachmentBuilder.cs create mode 100644 src/CalendarMcp.Core/Providers/MimeAttachmentBuilder.cs diff --git a/src/CalendarMcp.Core/Models/EmailAttachment.cs b/src/CalendarMcp.Core/Models/EmailAttachment.cs new file mode 100644 index 0000000..bd325cf --- /dev/null +++ b/src/CalendarMcp.Core/Models/EmailAttachment.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace CalendarMcp.Core.Models; + +/// +/// Represents a file to attach to an outbound email. The content is supplied +/// as a base64-encoded string so the attachment can be transported through MCP +/// without filesystem access on the server. +/// +public sealed class EmailAttachment +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("contentType")] + public string? ContentType { get; set; } + + [JsonPropertyName("base64Content")] + public string Base64Content { get; set; } = ""; + + /// + /// Decodes into raw bytes. Throws + /// if the value is not valid base64. + /// + public byte[] DecodeBytes() => Convert.FromBase64String(Base64Content); +} diff --git a/src/CalendarMcp.Core/Providers/GoogleProviderService.cs b/src/CalendarMcp.Core/Providers/GoogleProviderService.cs index eb46eb4..d4b842f 100644 --- a/src/CalendarMcp.Core/Providers/GoogleProviderService.cs +++ b/src/CalendarMcp.Core/Providers/GoogleProviderService.cs @@ -10,6 +10,8 @@ using Google.Apis.Services; using Google.Apis.Util.Store; using Microsoft.Extensions.Logging; +using MimeKit; +using MimeKit.Text; using System.Text; using Person = Google.Apis.PeopleService.v1.Data.Person; using Event = Google.Apis.Calendar.v3.Data.Event; @@ -273,12 +275,13 @@ public async Task> SearchEmailsAsync( } public async Task SendEmailAsync( - string accountId, - string to, - string subject, - string body, - string bodyFormat = "html", - List? cc = null, + string accountId, + string to, + string subject, + string body, + string bodyFormat = "html", + List? cc = null, + IReadOnlyList? attachments = null, CancellationToken cancellationToken = default) { var credential = await GetCredentialAsync(accountId, cancellationToken); @@ -291,31 +294,49 @@ public async Task SendEmailAsync( { var service = CreateGmailService(credential); - // Create the email message in RFC 2822 format - var messageBuilder = new StringBuilder(); - messageBuilder.AppendLine($"To: {to}"); - if (cc != null && cc.Count > 0) + var mime = new MimeMessage(); + foreach (var addr in to.Split(',', ';')) { - messageBuilder.AppendLine($"Cc: {string.Join(",", cc)}"); + var trimmed = addr.Trim(); + if (trimmed.Length > 0) + mime.To.Add(MailboxAddress.Parse(trimmed)); } - messageBuilder.AppendLine($"Subject: {subject}"); - - if (bodyFormat.Equals("html", StringComparison.OrdinalIgnoreCase)) + if (cc is { Count: > 0 }) { - messageBuilder.AppendLine("Content-Type: text/html; charset=utf-8"); + foreach (var addr in cc) + { + var trimmed = addr.Trim(); + if (trimmed.Length > 0) + mime.Cc.Add(MailboxAddress.Parse(trimmed)); + } } + mime.Subject = subject; + + var builder = new BodyBuilder(); + if (bodyFormat.Equals("html", StringComparison.OrdinalIgnoreCase)) + builder.HtmlBody = body; else + builder.TextBody = body; + + if (attachments is { Count: > 0 }) { - messageBuilder.AppendLine("Content-Type: text/plain; charset=utf-8"); + foreach (var att in attachments) + { + MimeAttachmentBuilder.Add(builder, att); + } } - - messageBuilder.AppendLine(); - messageBuilder.AppendLine(body); - var rawMessage = Convert.ToBase64String(Encoding.UTF8.GetBytes(messageBuilder.ToString())) - .Replace('+', '-') - .Replace('/', '_') - .Replace("=", ""); + mime.Body = builder.ToMessageBody(); + + string rawMessage; + using (var ms = new MemoryStream()) + { + await mime.WriteToAsync(ms, cancellationToken); + rawMessage = Convert.ToBase64String(ms.ToArray()) + .Replace('+', '-') + .Replace('/', '_') + .Replace("=", ""); + } var gmailMessage = new Message { @@ -334,6 +355,7 @@ public async Task SendEmailAsync( } } + public async Task DeleteEmailAsync( string accountId, string emailId, diff --git a/src/CalendarMcp.Core/Providers/GraphAttachmentBuilder.cs b/src/CalendarMcp.Core/Providers/GraphAttachmentBuilder.cs new file mode 100644 index 0000000..6c8e1da --- /dev/null +++ b/src/CalendarMcp.Core/Providers/GraphAttachmentBuilder.cs @@ -0,0 +1,55 @@ +using CalendarMcp.Core.Models; +using Microsoft.Graph.Models; + +namespace CalendarMcp.Core.Providers; + +/// +/// Converts values into Microsoft Graph +/// entries for inline use on the SendMail +/// endpoint. Graph caps the SendMail request at ~4 MB total, so each +/// attachment is rejected above 3 MB to leave headroom for the body and +/// recipients. Larger files would require the draft + upload-session flow, +/// which is not yet supported. +/// +internal static class GraphAttachmentBuilder +{ + private const int MaxAttachmentBytes = 3 * 1024 * 1024; + + public static List Build(IReadOnlyList attachments) + { + var result = new List(attachments.Count); + foreach (var att in attachments) + { + if (string.IsNullOrWhiteSpace(att.Name)) + { + throw new ArgumentException("Attachment name is required.", nameof(attachments)); + } + + byte[] bytes; + try + { + bytes = att.DecodeBytes(); + } + catch (FormatException ex) + { + throw new ArgumentException( + $"Attachment '{att.Name}' has invalid base64 content.", nameof(attachments), ex); + } + + if (bytes.Length > MaxAttachmentBytes) + { + throw new ArgumentException( + $"Attachment '{att.Name}' is {bytes.Length:N0} bytes; the Microsoft Graph SendMail endpoint limits each attachment to {MaxAttachmentBytes:N0} bytes.", + nameof(attachments)); + } + + result.Add(new FileAttachment + { + Name = att.Name, + ContentType = att.ContentType, + ContentBytes = bytes, + }); + } + return result; + } +} diff --git a/src/CalendarMcp.Core/Providers/IcsProviderService.cs b/src/CalendarMcp.Core/Providers/IcsProviderService.cs index 31f22a0..238a9b0 100644 --- a/src/CalendarMcp.Core/Providers/IcsProviderService.cs +++ b/src/CalendarMcp.Core/Providers/IcsProviderService.cs @@ -266,6 +266,7 @@ public Task> SearchEmailsAsync( public Task SendEmailAsync( string accountId, string to, string subject, string body, string bodyFormat = "html", List? cc = null, + IReadOnlyList? attachments = null, CancellationToken cancellationToken = default) => throw new NotSupportedException("ICS provider does not support sending emails."); diff --git a/src/CalendarMcp.Core/Providers/ImapProviderService.cs b/src/CalendarMcp.Core/Providers/ImapProviderService.cs index 2ed4520..d2b045d 100644 --- a/src/CalendarMcp.Core/Providers/ImapProviderService.cs +++ b/src/CalendarMcp.Core/Providers/ImapProviderService.cs @@ -274,6 +274,7 @@ public async Task> SearchEmailsAsync( public async Task SendEmailAsync( string accountId, string to, string subject, string body, string bodyFormat = "html", List? cc = null, + IReadOnlyList? attachments = null, CancellationToken cancellationToken = default) { var cfg = await ResolveConfigAsync(accountId); @@ -288,10 +289,27 @@ public async Task SendEmailAsync( message.Cc.Add(MailboxAddress.Parse(addr)); } message.Subject = subject; - message.Body = new TextPart(bodyFormat?.Equals("text", StringComparison.OrdinalIgnoreCase) == true - ? TextFormat.Plain - : TextFormat.Html) - { Text = body }; + + if (attachments is { Count: > 0 }) + { + var builder = new BodyBuilder(); + if (bodyFormat?.Equals("text", StringComparison.OrdinalIgnoreCase) == true) + builder.TextBody = body; + else + builder.HtmlBody = body; + + foreach (var att in attachments) + MimeAttachmentBuilder.Add(builder, att); + + message.Body = builder.ToMessageBody(); + } + else + { + message.Body = new TextPart(bodyFormat?.Equals("text", StringComparison.OrdinalIgnoreCase) == true + ? TextFormat.Plain + : TextFormat.Html) + { Text = body }; + } using (var smtp = await OpenSmtpAsync(cfg, cancellationToken)) { diff --git a/src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs b/src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs index 4dba1f1..74c80b7 100644 --- a/src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs +++ b/src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs @@ -420,6 +420,7 @@ public async Task> SearchEmailsAsync( public Task SendEmailAsync( string accountId, string to, string subject, string body, string bodyFormat = "html", List? cc = null, + IReadOnlyList? attachments = null, CancellationToken cancellationToken = default) => throw new NotSupportedException("JSON file provider is read-only; emails cannot be sent."); diff --git a/src/CalendarMcp.Core/Providers/M365ProviderService.cs b/src/CalendarMcp.Core/Providers/M365ProviderService.cs index b928145..e2a7502 100644 --- a/src/CalendarMcp.Core/Providers/M365ProviderService.cs +++ b/src/CalendarMcp.Core/Providers/M365ProviderService.cs @@ -282,12 +282,13 @@ public async Task> SearchEmailsAsync( } public async Task SendEmailAsync( - string accountId, - string to, - string subject, - string body, - string bodyFormat = "html", - List? cc = null, + string accountId, + string to, + string subject, + string body, + string bodyFormat = "html", + List? cc = null, + IReadOnlyList? attachments = null, CancellationToken cancellationToken = default) { var token = await GetAccessTokenAsync(accountId, cancellationToken); @@ -335,6 +336,11 @@ public async Task SendEmailAsync( .ToList(); } + if (attachments is { Count: > 0 }) + { + message.Attachments = GraphAttachmentBuilder.Build(attachments); + } + await graphClient.Me.SendMail.PostAsync(new SendMailPostRequestBody { Message = message, @@ -342,7 +348,7 @@ await graphClient.Me.SendMail.PostAsync(new SendMailPostRequestBody }, cancellationToken: cancellationToken); _logger.LogInformation("Email sent successfully from M365 account {AccountId} to {To}", accountId, to); - + // SendMail doesn't return a message ID, so we return a confirmation return $"sent-{DateTime.UtcNow:yyyyMMddHHmmss}"; } diff --git a/src/CalendarMcp.Core/Providers/MimeAttachmentBuilder.cs b/src/CalendarMcp.Core/Providers/MimeAttachmentBuilder.cs new file mode 100644 index 0000000..16f2eff --- /dev/null +++ b/src/CalendarMcp.Core/Providers/MimeAttachmentBuilder.cs @@ -0,0 +1,39 @@ +using CalendarMcp.Core.Models; +using MimeKit; + +namespace CalendarMcp.Core.Providers; + +/// +/// Adds entries to a MimeKit +/// . Used by providers that compose outbound email +/// as MIME (Gmail / IMAP-SMTP). +/// +internal static class MimeAttachmentBuilder +{ + public static void Add(BodyBuilder builder, EmailAttachment attachment) + { + if (string.IsNullOrWhiteSpace(attachment.Name)) + throw new ArgumentException("Attachment name is required.", nameof(attachment)); + + byte[] bytes; + try + { + bytes = attachment.DecodeBytes(); + } + catch (FormatException ex) + { + throw new ArgumentException( + $"Attachment '{attachment.Name}' has invalid base64 content.", nameof(attachment), ex); + } + + if (!string.IsNullOrWhiteSpace(attachment.ContentType) + && ContentType.TryParse(attachment.ContentType, out var ct)) + { + builder.Attachments.Add(attachment.Name, bytes, ct); + } + else + { + builder.Attachments.Add(attachment.Name, bytes); + } + } +} diff --git a/src/CalendarMcp.Core/Providers/OutlookComProviderService.cs b/src/CalendarMcp.Core/Providers/OutlookComProviderService.cs index a8d23ce..9ac9933 100644 --- a/src/CalendarMcp.Core/Providers/OutlookComProviderService.cs +++ b/src/CalendarMcp.Core/Providers/OutlookComProviderService.cs @@ -274,12 +274,13 @@ public async Task> SearchEmailsAsync( } public async Task SendEmailAsync( - string accountId, - string to, - string subject, - string body, - string bodyFormat = "html", - List? cc = null, + string accountId, + string to, + string subject, + string body, + string bodyFormat = "html", + List? cc = null, + IReadOnlyList? attachments = null, CancellationToken cancellationToken = default) { var token = await GetAccessTokenAsync(accountId, cancellationToken); @@ -327,6 +328,11 @@ public async Task SendEmailAsync( .ToList(); } + if (attachments is { Count: > 0 }) + { + message.Attachments = GraphAttachmentBuilder.Build(attachments); + } + await graphClient.Me.SendMail.PostAsync(new SendMailPostRequestBody { Message = message, @@ -334,7 +340,7 @@ await graphClient.Me.SendMail.PostAsync(new SendMailPostRequestBody }, cancellationToken: cancellationToken); _logger.LogInformation("Email sent successfully from Outlook.com account {AccountId} to {To}", accountId, to); - + return $"sent-{DateTime.UtcNow:yyyyMMddHHmmss}"; } catch (Exception ex) diff --git a/src/CalendarMcp.Core/Services/IProviderService.cs b/src/CalendarMcp.Core/Services/IProviderService.cs index 1c2a0aa..c3cb447 100644 --- a/src/CalendarMcp.Core/Services/IProviderService.cs +++ b/src/CalendarMcp.Core/Services/IProviderService.cs @@ -34,6 +34,7 @@ Task SendEmailAsync( string body, string bodyFormat = "html", List? cc = null, + IReadOnlyList? attachments = null, CancellationToken cancellationToken = default); Task DeleteEmailAsync( diff --git a/src/CalendarMcp.Core/Tools/SendEmailTool.cs b/src/CalendarMcp.Core/Tools/SendEmailTool.cs index 3687d30..1c947f5 100644 --- a/src/CalendarMcp.Core/Tools/SendEmailTool.cs +++ b/src/CalendarMcp.Core/Tools/SendEmailTool.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Text.Json; +using CalendarMcp.Core.Models; using CalendarMcp.Core.Services; using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; @@ -15,14 +16,19 @@ public sealed class SendEmailTool( IProviderServiceFactory providerFactory, ILogger logger) { - [McpServerTool, Description("Send an email. If accountId is omitted, smart routing selects the account whose domains match the first recipient's email domain; if no match, the first configured account is used. Provide accountId explicitly to guarantee which account sends the message.")] + // Total decoded attachment payload limit per message. Keeps the JSON tool + // call within agent limits and matches typical provider caps (~25 MB). + private const long MaxTotalAttachmentBytes = 25L * 1024 * 1024; + + [McpServerTool, Description("Send an email, optionally with file attachments. If accountId is omitted, smart routing selects the account whose domains match the first recipient's email domain; if no match, the first configured account is used. Provide accountId explicitly to guarantee which account sends the message.")] public async Task SendEmail( [Description("Recipient email address(es). Supply as a JSON array of strings, e.g. [\"alice@example.com\"] or [\"alice@example.com\",\"bob@example.com\"].")] List to, [Description("Email subject line")] string subject, [Description("Email body content. Use HTML when bodyFormat is 'html' (the default).")] string body, [Description("Account ID to send from. Omit to use smart routing (matches recipient domain to account domains, then falls back to first account). Obtain from list_accounts.")] string? accountId = null, [Description("Body content format: 'html' (default) or 'text'")] string bodyFormat = "html", - [Description("CC recipient email addresses")] List? cc = null) + [Description("CC recipient email addresses")] List? cc = null, + [Description("Optional file attachments as a JSON array. Each item: {\"name\":\"report.pdf\",\"contentType\":\"application/pdf\",\"base64Content\":\"\"}. contentType is optional. Total payload across all attachments must stay under 25 MB; Microsoft 365 and Outlook.com cap each attachment at 3 MB.")] List? attachments = null) { // Strip CDATA wrappers if present (LLMs sometimes wrap content in XML CDATA) body = StripCdataWrapper(body); @@ -35,6 +41,15 @@ public async Task SendEmail( }); } + if (attachments is { Count: > 0 }) + { + var validationError = ValidateAttachments(attachments); + if (validationError != null) + { + return JsonSerializer.Serialize(new { error = validationError }); + } + } + var toJoined = string.Join(", ", to); logger.LogInformation("Sending email: to={To}, subject={Subject}, accountId={AccountId}", @@ -99,7 +114,7 @@ public async Task SendEmail( // Send email var provider = providerFactory.GetProvider(account.Provider); var messageId = await provider.SendEmailAsync( - account.Id, toJoined, subject, body, bodyFormat, cc, CancellationToken.None); + account.Id, toJoined, subject, body, bodyFormat, cc, attachments, CancellationToken.None); var result = new { @@ -128,6 +143,34 @@ public async Task SendEmail( } } + private static string? ValidateAttachments(List attachments) + { + long total = 0; + for (var i = 0; i < attachments.Count; i++) + { + var att = attachments[i]; + if (string.IsNullOrWhiteSpace(att.Name)) + { + return $"attachments[{i}].name is required."; + } + if (string.IsNullOrEmpty(att.Base64Content)) + { + return $"attachments[{i}].base64Content is required for '{att.Name}'."; + } + // Cheap size estimate from the base64 string length, no allocation. + // Each 4 base64 chars decode to up to 3 bytes; this overestimates by + // up to 2 bytes per attachment, which is fine for the 25 MB cap. + var len = att.Base64Content.Length; + var estimatedBytes = (long)(len / 4) * 3; + total += estimatedBytes; + if (total > MaxTotalAttachmentBytes) + { + return $"Total attachment size exceeds {MaxTotalAttachmentBytes:N0} bytes."; + } + } + return null; + } + /// /// Strips CDATA wrappers from content if present. /// LLMs sometimes wrap HTML content in XML CDATA sections which are not valid HTML. diff --git a/src/CalendarMcp.Core/Tools/UnsubscribeFromEmailTool.cs b/src/CalendarMcp.Core/Tools/UnsubscribeFromEmailTool.cs index 73aa3d7..e329260 100644 --- a/src/CalendarMcp.Core/Tools/UnsubscribeFromEmailTool.cs +++ b/src/CalendarMcp.Core/Tools/UnsubscribeFromEmailTool.cs @@ -172,7 +172,7 @@ private static Models.UnsubscribeResult TryHttps(Models.UnsubscribeInfo info) try { var (to, subject, body) = parsed.Value; - await provider.SendEmailAsync(account.Id, to, subject, body, "text", null, CancellationToken.None); + await provider.SendEmailAsync(account.Id, to, subject, body, "text", null, null, CancellationToken.None); return new Models.UnsubscribeResult { diff --git a/src/CalendarMcp.Tests/Tools/SendEmailToolTests.cs b/src/CalendarMcp.Tests/Tools/SendEmailToolTests.cs index 333973c..c50c14f 100644 --- a/src/CalendarMcp.Tests/Tools/SendEmailToolTests.cs +++ b/src/CalendarMcp.Tests/Tools/SendEmailToolTests.cs @@ -23,7 +23,8 @@ public async Task SendEmail_SpecificAccount_Success() var provExp = new IProviderServiceCreateExpectations(); provExp.Setups.SendEmailAsync( "acc-1", "to@example.com", "Subject", "Body", Arg.Any(), - Arg.Any?>(), Arg.Any()) + Arg.Any?>(), Arg.Any?>(), + Arg.Any()) .ReturnValue(Task.FromResult("msg-123")); var factExp = new IProviderServiceFactoryCreateExpectations(); @@ -93,4 +94,107 @@ public async Task SendEmail_EmptyToList_ReturnsError() var error = doc.RootElement.GetProperty("error").GetString(); Assert.IsTrue(error?.Contains("recipient", StringComparison.OrdinalIgnoreCase) ?? false); } + + [TestMethod] + public async Task SendEmail_WithAttachment_PassesThroughToProvider() + { + var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365"); + + var regExp = new IAccountRegistryCreateExpectations(); + regExp.Setups.GetAccountAsync("acc-1") + .ReturnValue(Task.FromResult(account)); + + var provExp = new IProviderServiceCreateExpectations(); + provExp.Setups.SendEmailAsync( + "acc-1", "to@example.com", "Subject", "Body", Arg.Any(), + Arg.Any?>(), Arg.Any?>(), + Arg.Any()) + .ReturnValue(Task.FromResult("msg-123")); + + var factExp = new IProviderServiceFactoryCreateExpectations(); + factExp.Setups.GetProvider("microsoft365").ReturnValue(provExp.Instance()); + + var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), + NullLogger.Instance); + + var attachment = new EmailAttachment + { + Name = "hello.txt", + ContentType = "text/plain", + Base64Content = Convert.ToBase64String("hello"u8.ToArray()), + }; + + var result = await tool.SendEmail( + new List { "to@example.com" }, "Subject", "Body", "acc-1", + attachments: new List { attachment }); + + var doc = JsonDocument.Parse(result); + Assert.IsTrue(doc.RootElement.GetProperty("success").GetBoolean()); + + regExp.Verify(); + factExp.Verify(); + provExp.Verify(); + } + + [TestMethod] + public async Task SendEmail_AttachmentMissingName_ReturnsError() + { + var regExp = new IAccountRegistryCreateExpectations(); + var factExp = new IProviderServiceFactoryCreateExpectations(); + var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), + NullLogger.Instance); + + var result = await tool.SendEmail( + new List { "to@example.com" }, "Subject", "Body", "acc-1", + attachments: new List + { + new() { Name = "", Base64Content = "aGVsbG8=" }, + }); + + var doc = JsonDocument.Parse(result); + var error = doc.RootElement.GetProperty("error").GetString(); + Assert.IsTrue(error?.Contains("name is required", StringComparison.OrdinalIgnoreCase) ?? false); + } + + [TestMethod] + public async Task SendEmail_AttachmentMissingContent_ReturnsError() + { + var regExp = new IAccountRegistryCreateExpectations(); + var factExp = new IProviderServiceFactoryCreateExpectations(); + var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), + NullLogger.Instance); + + var result = await tool.SendEmail( + new List { "to@example.com" }, "Subject", "Body", "acc-1", + attachments: new List + { + new() { Name = "x.txt", Base64Content = "" }, + }); + + var doc = JsonDocument.Parse(result); + var error = doc.RootElement.GetProperty("error").GetString(); + Assert.IsTrue(error?.Contains("base64Content", StringComparison.OrdinalIgnoreCase) ?? false); + } + + [TestMethod] + public async Task SendEmail_AttachmentExceedsTotalCap_ReturnsError() + { + var regExp = new IAccountRegistryCreateExpectations(); + var factExp = new IProviderServiceFactoryCreateExpectations(); + var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), + NullLogger.Instance); + + // 35 MB of base64 chars decodes to ~26 MB, just over the 25 MB cap. + var bigBase64 = new string('A', 35 * 1024 * 1024); + var result = await tool.SendEmail( + new List { "to@example.com" }, "Subject", "Body", "acc-1", + attachments: new List + { + new() { Name = "a.bin", Base64Content = bigBase64 }, + }); + + var doc = JsonDocument.Parse(result); + var error = doc.RootElement.GetProperty("error").GetString(); + Assert.IsTrue(error?.Contains("exceeds", StringComparison.OrdinalIgnoreCase) ?? false); + } } From 2a800273af511b001b2e930b0618892ccdc7d558 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 23:25:05 +0000 Subject: [PATCH 2/8] Add dotnet test step to CI build workflow Agent-Logs-Url: https://github.com/MarimerLLC/calendar-mcp/sessions/64a0c95e-a90b-485d-b6f2-b2e5cca43d3e Co-authored-by: rockfordlhotka <2333134+rockfordlhotka@users.noreply.github.com> --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea936d8..5a9bfab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,6 @@ jobs: - name: Build solution run: dotnet build calendar-mcp.slnx --configuration Release --no-restore + + - name: Run tests + run: dotnet test calendar-mcp.slnx --configuration Release --no-build --verbosity normal From ed561bd9283b279f5c649e3bdeb557b43ebc85e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 23:44:47 +0000 Subject: [PATCH 3/8] Fix CI: rename outbound attachment type and disambiguate MessagePart The previous commit introduced a second EmailAttachment type that collided with the existing one in EmailMessage.cs (used for received- message attachment metadata). Renaming to OutboundEmailAttachment makes the inbound vs outbound distinction explicit and resolves the duplicate definition error. Adding using MimeKit; to GoogleProviderService also pulled in MimeKit.MessagePart, which conflicted with Google.Apis.Gmail.v1.Data.MessagePart. Added a using alias to bind the unqualified name to the Google type. https://claude.ai/code/session_01ETP8QfdTvDo9xBaDz5mbP8 --- ...ailAttachment.cs => OutboundEmailAttachment.cs} | 8 +++++--- .../Providers/GoogleProviderService.cs | 5 +++-- .../Providers/GraphAttachmentBuilder.cs | 4 ++-- .../Providers/IcsProviderService.cs | 2 +- .../Providers/ImapProviderService.cs | 2 +- .../Providers/JsonCalendarProviderService.cs | 2 +- .../Providers/M365ProviderService.cs | 2 +- .../Providers/MimeAttachmentBuilder.cs | 4 ++-- .../Providers/OutlookComProviderService.cs | 2 +- src/CalendarMcp.Core/Services/IProviderService.cs | 2 +- src/CalendarMcp.Core/Tools/SendEmailTool.cs | 4 ++-- src/CalendarMcp.Tests/Tools/SendEmailToolTests.cs | 14 +++++++------- 12 files changed, 27 insertions(+), 24 deletions(-) rename src/CalendarMcp.Core/Models/{EmailAttachment.cs => OutboundEmailAttachment.cs} (66%) diff --git a/src/CalendarMcp.Core/Models/EmailAttachment.cs b/src/CalendarMcp.Core/Models/OutboundEmailAttachment.cs similarity index 66% rename from src/CalendarMcp.Core/Models/EmailAttachment.cs rename to src/CalendarMcp.Core/Models/OutboundEmailAttachment.cs index bd325cf..a860190 100644 --- a/src/CalendarMcp.Core/Models/EmailAttachment.cs +++ b/src/CalendarMcp.Core/Models/OutboundEmailAttachment.cs @@ -3,11 +3,13 @@ namespace CalendarMcp.Core.Models; /// -/// Represents a file to attach to an outbound email. The content is supplied -/// as a base64-encoded string so the attachment can be transported through MCP +/// A file to attach to an outbound email being sent. Distinct from +/// , which represents an attachment on a +/// received message (metadata only). The content is supplied as a +/// base64-encoded string so the attachment can be transported through MCP /// without filesystem access on the server. /// -public sealed class EmailAttachment +public sealed class OutboundEmailAttachment { [JsonPropertyName("name")] public string Name { get; set; } = ""; diff --git a/src/CalendarMcp.Core/Providers/GoogleProviderService.cs b/src/CalendarMcp.Core/Providers/GoogleProviderService.cs index d4b842f..9b8772e 100644 --- a/src/CalendarMcp.Core/Providers/GoogleProviderService.cs +++ b/src/CalendarMcp.Core/Providers/GoogleProviderService.cs @@ -11,10 +11,11 @@ using Google.Apis.Util.Store; using Microsoft.Extensions.Logging; using MimeKit; -using MimeKit.Text; using System.Text; using Person = Google.Apis.PeopleService.v1.Data.Person; using Event = Google.Apis.Calendar.v3.Data.Event; +// Disambiguate from MimeKit.MessagePart, introduced by the MimeKit imports above. +using MessagePart = Google.Apis.Gmail.v1.Data.MessagePart; namespace CalendarMcp.Core.Providers; @@ -281,7 +282,7 @@ public async Task SendEmailAsync( string body, string bodyFormat = "html", List? cc = null, - IReadOnlyList? attachments = null, + IReadOnlyList? attachments = null, CancellationToken cancellationToken = default) { var credential = await GetCredentialAsync(accountId, cancellationToken); diff --git a/src/CalendarMcp.Core/Providers/GraphAttachmentBuilder.cs b/src/CalendarMcp.Core/Providers/GraphAttachmentBuilder.cs index 6c8e1da..5671af1 100644 --- a/src/CalendarMcp.Core/Providers/GraphAttachmentBuilder.cs +++ b/src/CalendarMcp.Core/Providers/GraphAttachmentBuilder.cs @@ -4,7 +4,7 @@ namespace CalendarMcp.Core.Providers; /// -/// Converts values into Microsoft Graph +/// Converts values into Microsoft Graph /// entries for inline use on the SendMail /// endpoint. Graph caps the SendMail request at ~4 MB total, so each /// attachment is rejected above 3 MB to leave headroom for the body and @@ -15,7 +15,7 @@ internal static class GraphAttachmentBuilder { private const int MaxAttachmentBytes = 3 * 1024 * 1024; - public static List Build(IReadOnlyList attachments) + public static List Build(IReadOnlyList attachments) { var result = new List(attachments.Count); foreach (var att in attachments) diff --git a/src/CalendarMcp.Core/Providers/IcsProviderService.cs b/src/CalendarMcp.Core/Providers/IcsProviderService.cs index 238a9b0..f490bc8 100644 --- a/src/CalendarMcp.Core/Providers/IcsProviderService.cs +++ b/src/CalendarMcp.Core/Providers/IcsProviderService.cs @@ -266,7 +266,7 @@ public Task> SearchEmailsAsync( public Task SendEmailAsync( string accountId, string to, string subject, string body, string bodyFormat = "html", List? cc = null, - IReadOnlyList? attachments = null, + IReadOnlyList? attachments = null, CancellationToken cancellationToken = default) => throw new NotSupportedException("ICS provider does not support sending emails."); diff --git a/src/CalendarMcp.Core/Providers/ImapProviderService.cs b/src/CalendarMcp.Core/Providers/ImapProviderService.cs index d2b045d..0c482e8 100644 --- a/src/CalendarMcp.Core/Providers/ImapProviderService.cs +++ b/src/CalendarMcp.Core/Providers/ImapProviderService.cs @@ -274,7 +274,7 @@ public async Task> SearchEmailsAsync( public async Task SendEmailAsync( string accountId, string to, string subject, string body, string bodyFormat = "html", List? cc = null, - IReadOnlyList? attachments = null, + IReadOnlyList? attachments = null, CancellationToken cancellationToken = default) { var cfg = await ResolveConfigAsync(accountId); diff --git a/src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs b/src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs index 74c80b7..8d30b26 100644 --- a/src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs +++ b/src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs @@ -420,7 +420,7 @@ public async Task> SearchEmailsAsync( public Task SendEmailAsync( string accountId, string to, string subject, string body, string bodyFormat = "html", List? cc = null, - IReadOnlyList? attachments = null, + IReadOnlyList? attachments = null, CancellationToken cancellationToken = default) => throw new NotSupportedException("JSON file provider is read-only; emails cannot be sent."); diff --git a/src/CalendarMcp.Core/Providers/M365ProviderService.cs b/src/CalendarMcp.Core/Providers/M365ProviderService.cs index e2a7502..c2e6f36 100644 --- a/src/CalendarMcp.Core/Providers/M365ProviderService.cs +++ b/src/CalendarMcp.Core/Providers/M365ProviderService.cs @@ -288,7 +288,7 @@ public async Task SendEmailAsync( string body, string bodyFormat = "html", List? cc = null, - IReadOnlyList? attachments = null, + IReadOnlyList? attachments = null, CancellationToken cancellationToken = default) { var token = await GetAccessTokenAsync(accountId, cancellationToken); diff --git a/src/CalendarMcp.Core/Providers/MimeAttachmentBuilder.cs b/src/CalendarMcp.Core/Providers/MimeAttachmentBuilder.cs index 16f2eff..ee17207 100644 --- a/src/CalendarMcp.Core/Providers/MimeAttachmentBuilder.cs +++ b/src/CalendarMcp.Core/Providers/MimeAttachmentBuilder.cs @@ -4,13 +4,13 @@ namespace CalendarMcp.Core.Providers; /// -/// Adds entries to a MimeKit +/// Adds entries to a MimeKit /// . Used by providers that compose outbound email /// as MIME (Gmail / IMAP-SMTP). /// internal static class MimeAttachmentBuilder { - public static void Add(BodyBuilder builder, EmailAttachment attachment) + public static void Add(BodyBuilder builder, OutboundEmailAttachment attachment) { if (string.IsNullOrWhiteSpace(attachment.Name)) throw new ArgumentException("Attachment name is required.", nameof(attachment)); diff --git a/src/CalendarMcp.Core/Providers/OutlookComProviderService.cs b/src/CalendarMcp.Core/Providers/OutlookComProviderService.cs index 9ac9933..c18d479 100644 --- a/src/CalendarMcp.Core/Providers/OutlookComProviderService.cs +++ b/src/CalendarMcp.Core/Providers/OutlookComProviderService.cs @@ -280,7 +280,7 @@ public async Task SendEmailAsync( string body, string bodyFormat = "html", List? cc = null, - IReadOnlyList? attachments = null, + IReadOnlyList? attachments = null, CancellationToken cancellationToken = default) { var token = await GetAccessTokenAsync(accountId, cancellationToken); diff --git a/src/CalendarMcp.Core/Services/IProviderService.cs b/src/CalendarMcp.Core/Services/IProviderService.cs index c3cb447..0c0812a 100644 --- a/src/CalendarMcp.Core/Services/IProviderService.cs +++ b/src/CalendarMcp.Core/Services/IProviderService.cs @@ -34,7 +34,7 @@ Task SendEmailAsync( string body, string bodyFormat = "html", List? cc = null, - IReadOnlyList? attachments = null, + IReadOnlyList? attachments = null, CancellationToken cancellationToken = default); Task DeleteEmailAsync( diff --git a/src/CalendarMcp.Core/Tools/SendEmailTool.cs b/src/CalendarMcp.Core/Tools/SendEmailTool.cs index 1c947f5..5cc5284 100644 --- a/src/CalendarMcp.Core/Tools/SendEmailTool.cs +++ b/src/CalendarMcp.Core/Tools/SendEmailTool.cs @@ -28,7 +28,7 @@ public async Task SendEmail( [Description("Account ID to send from. Omit to use smart routing (matches recipient domain to account domains, then falls back to first account). Obtain from list_accounts.")] string? accountId = null, [Description("Body content format: 'html' (default) or 'text'")] string bodyFormat = "html", [Description("CC recipient email addresses")] List? cc = null, - [Description("Optional file attachments as a JSON array. Each item: {\"name\":\"report.pdf\",\"contentType\":\"application/pdf\",\"base64Content\":\"\"}. contentType is optional. Total payload across all attachments must stay under 25 MB; Microsoft 365 and Outlook.com cap each attachment at 3 MB.")] List? attachments = null) + [Description("Optional file attachments as a JSON array. Each item: {\"name\":\"report.pdf\",\"contentType\":\"application/pdf\",\"base64Content\":\"\"}. contentType is optional. Total payload across all attachments must stay under 25 MB; Microsoft 365 and Outlook.com cap each attachment at 3 MB.")] List? attachments = null) { // Strip CDATA wrappers if present (LLMs sometimes wrap content in XML CDATA) body = StripCdataWrapper(body); @@ -143,7 +143,7 @@ public async Task SendEmail( } } - private static string? ValidateAttachments(List attachments) + private static string? ValidateAttachments(List attachments) { long total = 0; for (var i = 0; i < attachments.Count; i++) diff --git a/src/CalendarMcp.Tests/Tools/SendEmailToolTests.cs b/src/CalendarMcp.Tests/Tools/SendEmailToolTests.cs index c50c14f..99c12bf 100644 --- a/src/CalendarMcp.Tests/Tools/SendEmailToolTests.cs +++ b/src/CalendarMcp.Tests/Tools/SendEmailToolTests.cs @@ -23,7 +23,7 @@ public async Task SendEmail_SpecificAccount_Success() var provExp = new IProviderServiceCreateExpectations(); provExp.Setups.SendEmailAsync( "acc-1", "to@example.com", "Subject", "Body", Arg.Any(), - Arg.Any?>(), Arg.Any?>(), + Arg.Any?>(), Arg.Any?>(), Arg.Any()) .ReturnValue(Task.FromResult("msg-123")); @@ -107,7 +107,7 @@ public async Task SendEmail_WithAttachment_PassesThroughToProvider() var provExp = new IProviderServiceCreateExpectations(); provExp.Setups.SendEmailAsync( "acc-1", "to@example.com", "Subject", "Body", Arg.Any(), - Arg.Any?>(), Arg.Any?>(), + Arg.Any?>(), Arg.Any?>(), Arg.Any()) .ReturnValue(Task.FromResult("msg-123")); @@ -117,7 +117,7 @@ public async Task SendEmail_WithAttachment_PassesThroughToProvider() var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), NullLogger.Instance); - var attachment = new EmailAttachment + var attachment = new OutboundEmailAttachment { Name = "hello.txt", ContentType = "text/plain", @@ -126,7 +126,7 @@ public async Task SendEmail_WithAttachment_PassesThroughToProvider() var result = await tool.SendEmail( new List { "to@example.com" }, "Subject", "Body", "acc-1", - attachments: new List { attachment }); + attachments: new List { attachment }); var doc = JsonDocument.Parse(result); Assert.IsTrue(doc.RootElement.GetProperty("success").GetBoolean()); @@ -146,7 +146,7 @@ public async Task SendEmail_AttachmentMissingName_ReturnsError() var result = await tool.SendEmail( new List { "to@example.com" }, "Subject", "Body", "acc-1", - attachments: new List + attachments: new List { new() { Name = "", Base64Content = "aGVsbG8=" }, }); @@ -166,7 +166,7 @@ public async Task SendEmail_AttachmentMissingContent_ReturnsError() var result = await tool.SendEmail( new List { "to@example.com" }, "Subject", "Body", "acc-1", - attachments: new List + attachments: new List { new() { Name = "x.txt", Base64Content = "" }, }); @@ -188,7 +188,7 @@ public async Task SendEmail_AttachmentExceedsTotalCap_ReturnsError() var bigBase64 = new string('A', 35 * 1024 * 1024); var result = await tool.SendEmail( new List { "to@example.com" }, "Subject", "Body", "acc-1", - attachments: new List + attachments: new List { new() { Name = "a.bin", Base64Content = bigBase64 }, }); From a5e2d62738647d8568ed4604259417428b46869f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 23:53:26 +0000 Subject: [PATCH 4/8] Document email attachment support Fleshes out the send_email attachments parameter shape, adds it to the README tool list, and drops a changelog entry following the existing format under changelogs/. The MCP tool's [Description] attribute already describes the JSON shape inline, so it is left unchanged. https://claude.ai/code/session_01ETP8QfdTvDo9xBaDz5mbP8 --- README.md | 2 +- changelogs/2026-05-02-email-attachments.md | 174 +++++++++++++++++++++ docs/mcp-tools.md | 19 ++- 3 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 changelogs/2026-05-02-email-attachments.md diff --git a/README.md b/README.md index 673d1d0..fe6fd4f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ The server exposes these tools to AI assistants: - **get_emails** / **search_emails** — Read and search email across accounts - **get_email_details** — Get full email content - **get_contextual_email_summary** — AI-powered topic clustering and persona analysis -- **send_email** — Send email with smart domain-based routing +- **send_email** — Send email with smart domain-based routing; supports file attachments (base64-encoded, up to 25 MB total) - **list_calendars** / **get_calendar_events** — View calendars and events - **get_calendar_event_details** — Get full event details - **find_available_times** — Find free time across all calendars diff --git a/changelogs/2026-05-02-email-attachments.md b/changelogs/2026-05-02-email-attachments.md new file mode 100644 index 0000000..14a542c --- /dev/null +++ b/changelogs/2026-05-02-email-attachments.md @@ -0,0 +1,174 @@ +# 2026-05-02: Email Attachments + +## Summary + +Extended the `send_email` MCP tool to accept file attachments. Attachments +are passed inline as a JSON array of `{name, contentType, base64Content}` +objects, so they round-trip cleanly through any MCP client (Claude +Desktop, rockbot, etc.) without requiring filesystem access on the +server. Supported on Microsoft 365, Outlook.com, Google Workspace/Gmail, +and IMAP/SMTP accounts. + +## Changes Made + +### Core Library (CalendarMcp.Core) + +#### New Model +- `Models/OutboundEmailAttachment.cs` — payload type for outbound + attachments. Distinct from the existing `EmailAttachment` (which + represents inbound attachment metadata on a received message). + +#### Interface Update +- `Services/IProviderService.cs` — added an + `IReadOnlyList?` parameter to + `SendEmailAsync`. + +#### Provider Implementations +- `Providers/M365ProviderService.cs` — attaches via Microsoft Graph + `FileAttachment` on the `SendMail` request. +- `Providers/OutlookComProviderService.cs` — same as M365 (shared + Graph code path). +- `Providers/GraphAttachmentBuilder.cs` (new) — shared helper that + builds Graph `FileAttachment` instances and validates the + per-attachment 3 MB cap that the Graph SendMail endpoint enforces. +- `Providers/GoogleProviderService.cs` — switched from hand-rolled + RFC 2822 string assembly to MimeKit `BodyBuilder`, which handles + multipart/mixed correctly when attachments are present. +- `Providers/ImapProviderService.cs` — uses MimeKit `BodyBuilder` + when attachments are present; preserves the existing single-part + `TextPart` body when there are none. +- `Providers/MimeAttachmentBuilder.cs` (new) — shared helper used by + Google and IMAP/SMTP to add attachments to a MimeKit `BodyBuilder`. +- `Providers/IcsProviderService.cs` — stub continues to throw + `NotSupportedException` (read-only). +- `Providers/JsonCalendarProviderService.cs` — stub continues to + throw `NotSupportedException` (read-only). + +#### MCP Tool +- `Tools/SendEmailTool.cs` — added the `attachments` parameter with + a description that fully specifies the JSON shape so MCP clients + surface it correctly. Validates name, content, and total payload + size (25 MB cap) before forwarding to the provider. + +#### Misc +- `Tools/UnsubscribeFromEmailTool.cs` — updated to pass `null` for + the new attachments parameter on its internal `SendEmailAsync` + call. + +### Tests +- `Tests/Tools/SendEmailToolTests.cs` — updated existing mocks for + the new signature, plus four new tests: + - `SendEmail_WithAttachment_PassesThroughToProvider` + - `SendEmail_AttachmentMissingName_ReturnsError` + - `SendEmail_AttachmentMissingContent_ReturnsError` + - `SendEmail_AttachmentExceedsTotalCap_ReturnsError` + +### CI +- `.github/workflows/ci.yml` — gained a `dotnet test` step (added by + Copilot during this PR) so the new tests run on every push and PR. + +## API Details + +### Tool Signature +``` +send_email( + to: string[], + subject: string, + body: string, + accountId?: string, + bodyFormat?: "html" | "text", + cc?: string[], + attachments?: OutboundEmailAttachment[] +) -> JSON response +``` + +### Attachment Shape +```json +[ + { + "name": "report.pdf", + "contentType": "application/pdf", + "base64Content": "" + } +] +``` + +- **name** (required): file name as it should appear on the email +- **contentType** (optional): MIME type; sniffed by the provider if + omitted +- **base64Content** (required): file bytes encoded as one base64 + string + +### Limits + +| Scope | Limit | Source | +|-------|-------|--------| +| Per attachment, M365 / Outlook.com | 3 MB | Microsoft Graph SendMail endpoint cap | +| Per message, total decoded payload | 25 MB | Tool-level cap to keep MCP payloads tractable | +| Google / IMAP per-attachment | (no extra cap beyond the 25 MB total) | MimeKit handles arbitrary multipart sizes | + +Larger files are rejected with an explicit error message rather than +attempted and partially uploaded. + +## Provider Support + +| Provider | Supported | Implementation | +|----------|-----------|----------------| +| Microsoft 365 | Yes | Graph `FileAttachment` on `Me.SendMail` | +| Outlook.com | Yes | Graph `FileAttachment` on `Me.SendMail` | +| Google Workspace / Gmail | Yes | MimeKit `BodyBuilder` → base64url MIME → Gmail `Users.Messages.Send` | +| IMAP / SMTP | Yes | MimeKit `BodyBuilder` → SMTP send + IMAP APPEND to Sent | +| ICS | No | Read-only provider | +| JSON file | No | Read-only provider | + +## Usage Example + +``` +User: "Email the Q2 report to alice@example.com" +Assistant: [reads or generates the file] +Assistant: [calls send_email with attachments=[{ + name: "Q2-report.pdf", + contentType: "application/pdf", + base64Content: "JVBERi0xLjQK..." + }]] +Assistant: "Sent — Q2-report.pdf (842 KB) is on its way to alice." +``` + +## Design Notes + +- **Inline base64, not file paths.** Path-based attachments would require + the MCP server to share a filesystem with whatever produced the file, + which doesn't hold for hosted clients like Claude Desktop or rockbot. + Inline base64 keeps the tool call self-contained. +- **Type split.** `EmailAttachment` (existing, inbound) and + `OutboundEmailAttachment` (new) are kept distinct because they + describe different things — inbound carries metadata about an + attachment already on the server; outbound carries the actual bytes + to upload. +- **Why not Graph upload sessions for >3 MB on M365?** Implementing + the draft + upload-session + send flow is non-trivial and would be + deferred unless real usage shows demand. Today the tool returns a + clear error pointing at the 3 MB cap. + +## Files Changed + +- `src/CalendarMcp.Core/Models/OutboundEmailAttachment.cs` (new) +- `src/CalendarMcp.Core/Providers/GraphAttachmentBuilder.cs` (new) +- `src/CalendarMcp.Core/Providers/MimeAttachmentBuilder.cs` (new) +- `src/CalendarMcp.Core/Services/IProviderService.cs` +- `src/CalendarMcp.Core/Providers/M365ProviderService.cs` +- `src/CalendarMcp.Core/Providers/OutlookComProviderService.cs` +- `src/CalendarMcp.Core/Providers/GoogleProviderService.cs` +- `src/CalendarMcp.Core/Providers/ImapProviderService.cs` +- `src/CalendarMcp.Core/Providers/IcsProviderService.cs` +- `src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs` +- `src/CalendarMcp.Core/Tools/SendEmailTool.cs` +- `src/CalendarMcp.Core/Tools/UnsubscribeFromEmailTool.cs` +- `src/CalendarMcp.Tests/Tools/SendEmailToolTests.cs` +- `.github/workflows/ci.yml` (added `dotnet test` step) + +## Related Documentation + +- [Microsoft Graph: Send mail with attachments](https://learn.microsoft.com/en-us/graph/api/user-sendmail) +- [Gmail API: Users.messages.send](https://developers.google.com/gmail/api/reference/rest/v1/users.messages/send) +- [MimeKit BodyBuilder](http://www.mimekit.net/docs/html/T_MimeKit_BodyBuilder.htm) diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 7fae246..78a2c1d 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -209,7 +209,24 @@ Send email from specific account (requires explicit account selection or smart r - `body`: Email body content - `bodyFormat` (default: "html"): "html" or "text" - `cc` (optional): CC recipients -- `attachments` (optional): Attachments to include +- `attachments` (optional): File attachments. JSON array of objects: + ```json + [ + { + "name": "report.pdf", + "contentType": "application/pdf", + "base64Content": "" + } + ] + ``` + - `name` (required): file name as it should appear on the email + - `contentType` (optional): MIME type; sniffed by the provider if omitted + - `base64Content` (required): the file's bytes encoded as a single base64 string + + Limits: total decoded payload across all attachments must stay under **25 MB**. + Microsoft 365 and Outlook.com additionally cap each individual attachment at + **3 MB** (the Graph SendMail endpoint limit). Larger files are rejected with a + clear error. ICS and JSON file providers are read-only and reject any send. **Returns**: ```json From b6f2108d23ab38a0a4d6ea392df27885cc6e01ca Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 3 May 2026 22:09:03 -0500 Subject: [PATCH 5/8] Add HTTP upload endpoint for email attachments Lets agents POST a file once and pass back a short-lived attachmentId on send_email instead of inlining base64 in the JSON tool call. Cuts token overhead for Claude Code and rockbot, and avoids the LLM having to emit megabytes of base64 inline. - POST /attachments (multipart) on the HTTP server, returns an ID with a 15-min absolute TTL. - In-memory store (no PVC); per-upload cap 10 MB, global cap 100 MB, 60 s background eviction sweeper. - send_email now accepts either base64Content (existing inline path, required for stdio + Copilot) or attachmentId per attachment. - Single-use IDs; shape errors don't consume entries. Bumps VersionPrefix to 1.2.3. Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Build.props | 2 +- .../2026-05-03-attachment-upload-endpoint.md | 122 ++++++++++++ docs/mcp-tools.md | 66 +++++-- .../ServiceCollectionExtensions.cs | 6 + .../Models/OutboundEmailAttachment.cs | 32 ++- .../Services/IAttachmentStore.cs | 44 +++++ .../Services/InMemoryAttachmentStore.cs | 128 ++++++++++++ src/CalendarMcp.Core/Tools/SendEmailTool.cs | 108 ++++++++-- .../Endpoints/AttachmentEndpoints.cs | 97 +++++++++ .../Endpoints/AttachmentEvictionService.cs | 39 ++++ src/CalendarMcp.HttpServer/Program.cs | 9 + .../Helpers/TestAttachmentStore.cs | 49 +++++ .../Services/InMemoryAttachmentStoreTests.cs | 111 +++++++++++ .../Tools/SendEmailToolAttachmentIdTests.cs | 184 ++++++++++++++++++ .../Tools/SendEmailToolMcpInvocationTests.cs | 95 +++++++++ .../Tools/SendEmailToolTests.cs | 16 +- 16 files changed, 1063 insertions(+), 45 deletions(-) create mode 100644 changelogs/2026-05-03-attachment-upload-endpoint.md create mode 100644 src/CalendarMcp.Core/Services/IAttachmentStore.cs create mode 100644 src/CalendarMcp.Core/Services/InMemoryAttachmentStore.cs create mode 100644 src/CalendarMcp.HttpServer/Endpoints/AttachmentEndpoints.cs create mode 100644 src/CalendarMcp.HttpServer/Endpoints/AttachmentEvictionService.cs create mode 100644 src/CalendarMcp.Tests/Helpers/TestAttachmentStore.cs create mode 100644 src/CalendarMcp.Tests/Services/InMemoryAttachmentStoreTests.cs create mode 100644 src/CalendarMcp.Tests/Tools/SendEmailToolAttachmentIdTests.cs create mode 100644 src/CalendarMcp.Tests/Tools/SendEmailToolMcpInvocationTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 77bc123..e62eab6 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 1.2.2 + 1.2.3 diff --git a/changelogs/2026-05-03-attachment-upload-endpoint.md b/changelogs/2026-05-03-attachment-upload-endpoint.md new file mode 100644 index 0000000..38befbc --- /dev/null +++ b/changelogs/2026-05-03-attachment-upload-endpoint.md @@ -0,0 +1,122 @@ +# 2026-05-03: Attachment Upload Endpoint + +## Summary + +Adds an out-of-band HTTP upload path so agents can deliver attachment bytes +to the MCP server *as binary* — without inflating their JSON tool call with +megabytes of base64. Builds on the email-attachment work from 2026-05-02. + +`POST /attachments` (multipart) returns a short-lived `attachmentId`; the +existing `send_email` tool now accepts that ID in lieu of `base64Content` per +attachment. The server resolves the ID to bytes server-side and consumes the +entry on send (single-use). + +Inline `base64Content` continues to work for stdio MCP clients (Claude +Desktop, the local stdio mode of Claude Code) and tiny files where the +upload round trip isn't worth it. M365 Copilot also stays on inline base64. + +## Why + +Base64-in-JSON is the only universal MCP shape, but it has real costs: + +- **Token bloat**: a 2 MB PDF becomes ~2.7 MB of base64 chars sitting in the + agent's tool call, eating into the context window. +- **LLM emission**: hosted agents that must *emit* the base64 themselves + (rather than execute code that reads the file) hit truncation and + reliability issues quickly. +- **Latency**: bytes traverse the LLM round trip even though the model has + no use for them. + +The k8s deployment has no shared filesystem, so a `filePath` parameter +isn't an option. An out-of-band HTTP upload is the cleanest fit: agents +that can `curl` (Claude Code, rockbot) skip base64 entirely; everyone else +keeps the inline path. + +## Changes Made + +### Core Library (CalendarMcp.Core) + +#### New +- `Services/IAttachmentStore.cs` — interface, `StoredAttachment` record, + `AttachmentStoreOptions` (per-attachment 10 MB, global 100 MB, 15 min TTL). +- `Services/InMemoryAttachmentStore.cs` — single-process dictionary-backed + store. 22-char base64url IDs from `RandomNumberGenerator`. Lock-guarded + `Put`/`TryConsume`. Single-use semantics (consume removes the entry). + Lazy expiry on `TryConsume`; explicit `EvictExpired` for the sweeper. +- `Configuration/ServiceCollectionExtensions.cs` — register + `IAttachmentStore` (singleton) and `AttachmentStoreOptions`. + +#### Updated +- `Models/OutboundEmailAttachment.cs` — `Base64Content` is now nullable; + added `AttachmentId`. Exactly one of the two must be set per item. +- `Tools/SendEmailTool.cs` — injects `IAttachmentStore`. Two-phase handling: + first pass validates shapes (rejects both/neither, missing `name` for + inline) without touching the store; second pass consumes any + `AttachmentId` entries and substitutes the resolved bytes before + dispatching to the provider. Provider implementations are unchanged. + +### HTTP Server (CalendarMcp.HttpServer) + +#### New +- `Endpoints/AttachmentEndpoints.cs` — `POST /attachments` minimal API. + Accepts `multipart/form-data` with one `file` part. Returns `201` with + `{ attachmentId, name, contentType, size, expiresAt }`. Errors: `400` + (bad shape), `413` (per-file cap), `507` (global cap). +- `Endpoints/AttachmentEvictionService.cs` — `BackgroundService` sweeping + the store every 60 s. Without it, expired entries would only be reclaimed + on next access. + +#### Updated +- `Program.cs` — registers `AttachmentEvictionService` as a hosted service + and maps the upload endpoint as a sibling of `/mcp`. Same network-level + protection (Tailscale ACL, reverse proxy) — no admin token required, so + rockbot can `curl` directly. + +### Stdio Server (CalendarMcp.StdioServer) + +No changes. Stdio clients have no second channel for uploads, so they +continue to use `base64Content` exclusively. The schema field is visible to +them but passing an `attachmentId` will fail with "unknown or expired." + +### Documentation + +- `docs/mcp-tools.md` — rewrote the `attachments` parameter description to + cover both shapes; added an "Attachment uploads (HTTP mode)" section with + the request/response shape, limits, and a curl recipe. + +### Tests + +- `Services/InMemoryAttachmentStoreTests.cs` (5) — round-trip, oversized + file, global cap, expiry on consume, eviction reclaims quota. +- `Tools/SendEmailToolAttachmentIdTests.cs` (5) — happy path, name/content + type override, unknown ID, both-shapes rejection, neither-shape rejection. +- `Helpers/TestAttachmentStore.cs` — minimal `IAttachmentStore` for tests + (`Seed` to insert, `ConsumedIds` to assert). +- `Tools/SendEmailToolTests.cs`, `Tools/SendEmailToolMcpInvocationTests.cs` — + updated constructor call to include the new dependency. + +## Limits + +| Limit | Value | Rationale | +|---|---|---| +| Per-upload size | 10 MB | Generous for any practical email attachment; well above Graph's 3 MB cap. | +| Global store size | 100 MB | Caps worst-case pod memory pressure from attachments. | +| TTL | 15 min | Long enough for compose-then-send workflows; short enough that abandoned uploads clear quickly. | +| Sweep interval | 60 s | Reclaims memory shortly after expiry without spinning. | + +## Storage + +In-process memory only. No persistent volume, no disk writes. Pod restarts +discard any in-flight uploads — the agent re-uploads on retry. If the +HttpServer is ever scaled beyond one replica, swap `InMemoryAttachmentStore` +for a Redis-backed implementation; the interface stays the same. + +## Security Notes + +- The endpoint sits at `/attachments`, sibling of `/mcp`, behind the same + network-level protection (Tailscale ACL). No admin token required. +- Single-use consumption prevents replay confusion: once `send_email` + succeeds, the ID is gone. +- The 100 MB global cap is the upper bound on memory abuse from a confused + or hostile reachable client. +- IDs are 128 random bits (base64url-encoded), unguessable. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 78a2c1d..b1c7cc2 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -209,24 +209,68 @@ Send email from specific account (requires explicit account selection or smart r - `body`: Email body content - `bodyFormat` (default: "html"): "html" or "text" - `cc` (optional): CC recipients -- `attachments` (optional): File attachments. JSON array of objects: +- `attachments` (optional): File attachments. JSON array of objects. Each item + must use one of two shapes: + + **Preferred — by ID (out-of-band upload):** ```json - [ - { - "name": "report.pdf", - "contentType": "application/pdf", - "base64Content": "" - } - ] + [{ "attachmentId": "AbCdEf1234..." }] + ``` + Upload the file to `POST /attachments` first (see [Attachment uploads](#attachment-uploads-http-mode)), + then pass the returned `attachmentId`. Avoids inflating the JSON tool call + with megabytes of base64. `name` and `contentType` from the upload are used + by default; pass them on the attachment object to override. + + **Inline — by base64 (fallback):** + ```json + [{ + "name": "report.pdf", + "contentType": "application/pdf", + "base64Content": "" + }] ``` - `name` (required): file name as it should appear on the email - `contentType` (optional): MIME type; sniffed by the provider if omitted - `base64Content` (required): the file's bytes encoded as a single base64 string - Limits: total decoded payload across all attachments must stay under **25 MB**. + Use inline only for small files or when no out-of-band upload channel is + available (e.g. stdio MCP clients). + + Limits: total decoded payload per message must stay under **25 MB**. Microsoft 365 and Outlook.com additionally cap each individual attachment at - **3 MB** (the Graph SendMail endpoint limit). Larger files are rejected with a - clear error. ICS and JSON file providers are read-only and reject any send. + **3 MB** (the Graph SendMail endpoint limit). Larger files are rejected with + a clear error. ICS and JSON file providers are read-only and reject any send. + +### Attachment uploads (HTTP mode) + +Available only on the HTTP server (`CalendarMcp.HttpServer`). Stdio clients +must use `base64Content` inline. + +`POST /attachments` accepts `multipart/form-data` with exactly one `file` +part and returns: + +```json +{ + "attachmentId": "AbCdEf1234...", + "name": "report.pdf", + "contentType": "application/pdf", + "size": 524288, + "expiresAt": "2026-05-03T15:42:00+00:00" +} +``` + +Pass the returned `attachmentId` in a subsequent `send_email` call. Entries +are **single-use** (the server consumes the bytes on send) and expire **15 +minutes** after upload. Per-upload limit: **10 MB**. Server-wide cap on the +live attachment store: **100 MB**. + +Storage is in-process memory only — no disk, no PVC. Pod restart drops any +unsent uploads; the agent should re-upload on retry. + +curl example: +```sh +curl -F file=@report.pdf https://calendar-mcp.tail920062.ts.net/attachments +``` **Returns**: ```json diff --git a/src/CalendarMcp.Core/Configuration/ServiceCollectionExtensions.cs b/src/CalendarMcp.Core/Configuration/ServiceCollectionExtensions.cs index 0fb5d94..11f963d 100644 --- a/src/CalendarMcp.Core/Configuration/ServiceCollectionExtensions.cs +++ b/src/CalendarMcp.Core/Configuration/ServiceCollectionExtensions.cs @@ -47,6 +47,12 @@ public static IServiceCollection AddCalendarMcpCore(this IServiceCollection serv // Register account registry services.AddSingleton(); + + // Attachment store (in-memory; eviction sweeper is registered by the + // HTTP server only — stdio mode never uploads, so lazy expiry on + // consume is sufficient there). + services.AddOptions(); + services.AddSingleton(); // Register MCP tools (method-based pattern - just register the classes) services.AddSingleton(); diff --git a/src/CalendarMcp.Core/Models/OutboundEmailAttachment.cs b/src/CalendarMcp.Core/Models/OutboundEmailAttachment.cs index a860190..b22341e 100644 --- a/src/CalendarMcp.Core/Models/OutboundEmailAttachment.cs +++ b/src/CalendarMcp.Core/Models/OutboundEmailAttachment.cs @@ -5,10 +5,21 @@ namespace CalendarMcp.Core.Models; /// /// A file to attach to an outbound email being sent. Distinct from /// , which represents an attachment on a -/// received message (metadata only). The content is supplied as a -/// base64-encoded string so the attachment can be transported through MCP -/// without filesystem access on the server. +/// received message (metadata only). /// +/// +/// Exactly one of or +/// must be set per item: +/// +/// : inline bytes, base64-encoded. +/// Convenient for tiny files but inflates the JSON tool call ~33% and +/// fights LLM token limits. +/// : ID returned by a prior +/// POST /attachments upload. Preferred for anything non-trivial; the +/// server resolves the ID to bytes server-side and consumes the entry on +/// success (single-use). +/// +/// public sealed class OutboundEmailAttachment { [JsonPropertyName("name")] @@ -18,11 +29,20 @@ public sealed class OutboundEmailAttachment public string? ContentType { get; set; } [JsonPropertyName("base64Content")] - public string Base64Content { get; set; } = ""; + public string? Base64Content { get; set; } + + [JsonPropertyName("attachmentId")] + public string? AttachmentId { get; set; } /// /// Decodes into raw bytes. Throws - /// if the value is not valid base64. + /// if the value is not valid base64, or + /// if no inline content is set. /// - public byte[] DecodeBytes() => Convert.FromBase64String(Base64Content); + public byte[] DecodeBytes() + { + if (string.IsNullOrEmpty(Base64Content)) + throw new InvalidOperationException("No inline base64 content to decode."); + return Convert.FromBase64String(Base64Content); + } } diff --git a/src/CalendarMcp.Core/Services/IAttachmentStore.cs b/src/CalendarMcp.Core/Services/IAttachmentStore.cs new file mode 100644 index 0000000..b04f10e --- /dev/null +++ b/src/CalendarMcp.Core/Services/IAttachmentStore.cs @@ -0,0 +1,44 @@ +namespace CalendarMcp.Core.Services; + +/// +/// Server-side temporary store for binary attachments uploaded out-of-band by +/// agents. Lets the send_email tool reference bytes by short ID instead +/// of inlining a base64 payload in JSON tool arguments. +/// +public interface IAttachmentStore +{ + /// + /// Stores under a freshly generated ID. + /// Returns null if the global byte budget would be exceeded. + /// + StoredAttachment? Put(string name, string? contentType, byte[] bytes); + + /// + /// Atomically removes and returns the entry. Returns null if the + /// ID is unknown or already expired. + /// + StoredAttachment? TryConsume(string attachmentId); + + /// + /// Removes any entries past their absolute expiry. Safe to call from a + /// background sweeper; also rejects expired + /// entries lazily. + /// + void EvictExpired(); +} + +public sealed class StoredAttachment +{ + public required string Id { get; init; } + public required string Name { get; init; } + public string? ContentType { get; init; } + public required byte[] Bytes { get; init; } + public required DateTimeOffset ExpiresAt { get; init; } +} + +public sealed class AttachmentStoreOptions +{ + public int MaxBytesPerAttachment { get; set; } = 10 * 1024 * 1024; + public long MaxTotalBytes { get; set; } = 100L * 1024 * 1024; + public TimeSpan Ttl { get; set; } = TimeSpan.FromMinutes(15); +} diff --git a/src/CalendarMcp.Core/Services/InMemoryAttachmentStore.cs b/src/CalendarMcp.Core/Services/InMemoryAttachmentStore.cs new file mode 100644 index 0000000..0a2e214 --- /dev/null +++ b/src/CalendarMcp.Core/Services/InMemoryAttachmentStore.cs @@ -0,0 +1,128 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CalendarMcp.Core.Services; + +/// +/// Single-process attachment store backed by a dictionary. Suitable for the +/// current single-pod deployment; switch to a distributed store if the +/// HttpServer is ever scaled out. +/// +public sealed class InMemoryAttachmentStore : IAttachmentStore +{ + private readonly Dictionary _entries = new(StringComparer.Ordinal); + private readonly Lock _gate = new(); + private readonly AttachmentStoreOptions _options; + private readonly ILogger _logger; + private readonly TimeProvider _time; + private long _totalBytes; + + public InMemoryAttachmentStore( + IOptions options, + ILogger logger, + TimeProvider? timeProvider = null) + { + _options = options.Value; + _logger = logger; + _time = timeProvider ?? TimeProvider.System; + } + + public StoredAttachment? Put(string name, string? contentType, byte[] bytes) + { + ArgumentNullException.ThrowIfNull(bytes); + if (bytes.Length > _options.MaxBytesPerAttachment) + { + return null; + } + + var id = NewId(); + var entry = new StoredAttachment + { + Id = id, + Name = string.IsNullOrWhiteSpace(name) ? "attachment" : name, + ContentType = contentType, + Bytes = bytes, + ExpiresAt = _time.GetUtcNow().Add(_options.Ttl), + }; + + lock (_gate) + { + EvictExpiredCore(); + if (_totalBytes + bytes.Length > _options.MaxTotalBytes) + { + return null; + } + _entries[id] = entry; + _totalBytes += bytes.Length; + } + + _logger.LogDebug("Stored attachment {Id} ({Name}, {Size} bytes), expires {ExpiresAt:O}", + id, entry.Name, bytes.Length, entry.ExpiresAt); + return entry; + } + + public StoredAttachment? TryConsume(string attachmentId) + { + if (string.IsNullOrEmpty(attachmentId)) + return null; + + lock (_gate) + { + if (!_entries.TryGetValue(attachmentId, out var entry)) + return null; + + _entries.Remove(attachmentId); + _totalBytes -= entry.Bytes.Length; + + if (entry.ExpiresAt <= _time.GetUtcNow()) + { + _logger.LogDebug("Attachment {Id} was past expiry on consume", attachmentId); + return null; + } + + return entry; + } + } + + public void EvictExpired() + { + lock (_gate) + { + EvictExpiredCore(); + } + } + + private void EvictExpiredCore() + { + var now = _time.GetUtcNow(); + List? toRemove = null; + foreach (var kv in _entries) + { + if (kv.Value.ExpiresAt <= now) + { + (toRemove ??= new List()).Add(kv.Key); + } + } + if (toRemove == null) return; + foreach (var id in toRemove) + { + if (_entries.Remove(id, out var entry)) + { + _totalBytes -= entry.Bytes.Length; + } + } + _logger.LogDebug("Evicted {Count} expired attachments", toRemove.Count); + } + + // 16 random bytes -> 22-char base64url id. + private static string NewId() + { + Span buf = stackalloc byte[16]; + RandomNumberGenerator.Fill(buf); + return Convert.ToBase64String(buf) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/src/CalendarMcp.Core/Tools/SendEmailTool.cs b/src/CalendarMcp.Core/Tools/SendEmailTool.cs index 5cc5284..00471f6 100644 --- a/src/CalendarMcp.Core/Tools/SendEmailTool.cs +++ b/src/CalendarMcp.Core/Tools/SendEmailTool.cs @@ -14,6 +14,7 @@ namespace CalendarMcp.Core.Tools; public sealed class SendEmailTool( IAccountRegistry accountRegistry, IProviderServiceFactory providerFactory, + IAttachmentStore attachmentStore, ILogger logger) { // Total decoded attachment payload limit per message. Keeps the JSON tool @@ -28,7 +29,7 @@ public async Task SendEmail( [Description("Account ID to send from. Omit to use smart routing (matches recipient domain to account domains, then falls back to first account). Obtain from list_accounts.")] string? accountId = null, [Description("Body content format: 'html' (default) or 'text'")] string bodyFormat = "html", [Description("CC recipient email addresses")] List? cc = null, - [Description("Optional file attachments as a JSON array. Each item: {\"name\":\"report.pdf\",\"contentType\":\"application/pdf\",\"base64Content\":\"\"}. contentType is optional. Total payload across all attachments must stay under 25 MB; Microsoft 365 and Outlook.com cap each attachment at 3 MB.")] List? attachments = null) + [Description("Optional file attachments as a JSON array. Each item must set EITHER \"attachmentId\" (preferred for anything non-trivial: upload the file via POST /attachments first to get the ID) OR \"base64Content\" (the raw bytes encoded as base64; use only for very small files). \"name\" is required when using base64Content and optional when using attachmentId. \"contentType\" is optional. Total decoded payload per message must stay under 25 MB; Microsoft 365 and Outlook.com cap each individual attachment at 3 MB. Examples: [{\"attachmentId\":\"AbCd...\"}] or [{\"name\":\"x.pdf\",\"base64Content\":\"...\"}].")] List? attachments = null) { // Strip CDATA wrappers if present (LLMs sometimes wrap content in XML CDATA) body = StripCdataWrapper(body); @@ -41,13 +42,24 @@ public async Task SendEmail( }); } + List? resolvedAttachments = null; if (attachments is { Count: > 0 }) { - var validationError = ValidateAttachments(attachments); - if (validationError != null) + var shapeError = ValidateAttachmentShapes(attachments); + if (shapeError != null) { - return JsonSerializer.Serialize(new { error = validationError }); + return JsonSerializer.Serialize(new { error = shapeError }); } + + // Second pass: consume any AttachmentId-backed entries. From this + // point on, IDs are removed from the store; an error after this + // requires the agent to re-upload. + var (resolved, consumeError) = ResolveAttachments(attachments); + if (consumeError != null) + { + return JsonSerializer.Serialize(new { error = consumeError }); + } + resolvedAttachments = resolved; } var toJoined = string.Join(", ", to); @@ -114,7 +126,7 @@ public async Task SendEmail( // Send email var provider = providerFactory.GetProvider(account.Provider); var messageId = await provider.SendEmailAsync( - account.Id, toJoined, subject, body, bodyFormat, cc, attachments, CancellationToken.None); + account.Id, toJoined, subject, body, bodyFormat, cc, resolvedAttachments, CancellationToken.None); var result = new { @@ -143,34 +155,92 @@ public async Task SendEmail( } } - private static string? ValidateAttachments(List attachments) + /// + /// First pass: structural validation only. Does NOT touch the attachment + /// store. Returns an error string for the caller, or null if all items are + /// well-formed. + /// + private static string? ValidateAttachmentShapes(List attachments) { - long total = 0; + long inlineEstimatedBytes = 0; for (var i = 0; i < attachments.Count; i++) { var att = attachments[i]; - if (string.IsNullOrWhiteSpace(att.Name)) + var hasInline = !string.IsNullOrEmpty(att.Base64Content); + var hasId = !string.IsNullOrEmpty(att.AttachmentId); + + if (hasInline && hasId) + { + return $"attachments[{i}]: set either 'base64Content' or 'attachmentId', not both."; + } + if (!hasInline && !hasId) { - return $"attachments[{i}].name is required."; + return $"attachments[{i}]: set either 'base64Content' (inline bytes) or 'attachmentId' (from POST /attachments)."; } - if (string.IsNullOrEmpty(att.Base64Content)) + if (hasInline && string.IsNullOrWhiteSpace(att.Name)) { - return $"attachments[{i}].base64Content is required for '{att.Name}'."; + return $"attachments[{i}].name is required when using base64Content."; } - // Cheap size estimate from the base64 string length, no allocation. - // Each 4 base64 chars decode to up to 3 bytes; this overestimates by - // up to 2 bytes per attachment, which is fine for the 25 MB cap. - var len = att.Base64Content.Length; - var estimatedBytes = (long)(len / 4) * 3; - total += estimatedBytes; - if (total > MaxTotalAttachmentBytes) + if (hasInline) { - return $"Total attachment size exceeds {MaxTotalAttachmentBytes:N0} bytes."; + // Cheap size estimate from base64 length; overestimates by up + // to 2 bytes per attachment, which is fine for the 25 MB cap. + var len = att.Base64Content!.Length; + inlineEstimatedBytes += (long)(len / 4) * 3; + if (inlineEstimatedBytes > MaxTotalAttachmentBytes) + { + return $"Total inline attachment size exceeds {MaxTotalAttachmentBytes:N0} bytes."; + } } } return null; } + /// + /// Second pass: resolves any + /// entries to inline bytes by consuming them from the store. Single-use: + /// successfully consumed IDs are removed, so an error after this point + /// requires the agent to re-upload. + /// + private (List? resolved, string? error) ResolveAttachments( + List attachments) + { + var result = new List(attachments.Count); + long totalBytes = 0; + + for (var i = 0; i < attachments.Count; i++) + { + var att = attachments[i]; + + if (!string.IsNullOrEmpty(att.AttachmentId)) + { + var stored = attachmentStore.TryConsume(att.AttachmentId); + if (stored == null) + { + return (null, $"attachments[{i}]: attachmentId '{att.AttachmentId}' is unknown or expired. Re-upload via POST /attachments."); + } + totalBytes += stored.Bytes.Length; + if (totalBytes > MaxTotalAttachmentBytes) + { + return (null, $"Total attachment size exceeds {MaxTotalAttachmentBytes:N0} bytes."); + } + result.Add(new OutboundEmailAttachment + { + Name = string.IsNullOrWhiteSpace(att.Name) ? stored.Name : att.Name, + ContentType = att.ContentType ?? stored.ContentType, + Base64Content = Convert.ToBase64String(stored.Bytes), + }); + } + else + { + // Inline base64 — already validated by ValidateAttachmentShapes. + result.Add(att); + } + } + + return (result, null); + } + /// /// Strips CDATA wrappers from content if present. /// LLMs sometimes wrap HTML content in XML CDATA sections which are not valid HTML. diff --git a/src/CalendarMcp.HttpServer/Endpoints/AttachmentEndpoints.cs b/src/CalendarMcp.HttpServer/Endpoints/AttachmentEndpoints.cs new file mode 100644 index 0000000..410ef6f --- /dev/null +++ b/src/CalendarMcp.HttpServer/Endpoints/AttachmentEndpoints.cs @@ -0,0 +1,97 @@ +using CalendarMcp.Core.Services; + +namespace CalendarMcp.HttpServer.Endpoints; + +public static class AttachmentEndpoints +{ + public static IEndpointRouteBuilder MapAttachmentEndpoints(this IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/attachments") + .WithTags("Attachments") + .DisableAntiforgery(); + + group.MapPost("/", UploadAsync) + .WithName("UploadAttachment") + .WithSummary("Upload a binary attachment for use in send_email.") + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status413PayloadTooLarge) + .ProducesProblem(StatusCodes.Status507InsufficientStorage); + + return routes; + } + + private static async Task UploadAsync( + HttpRequest request, + IAttachmentStore store, + ILogger logger, + CancellationToken cancellationToken) + { + if (!request.HasFormContentType) + { + return Results.Problem( + title: "Multipart form-data required", + detail: "POST a multipart/form-data body with exactly one file part.", + statusCode: StatusCodes.Status400BadRequest); + } + + IFormCollection form; + try + { + form = await request.ReadFormAsync(cancellationToken); + } + catch (Exception ex) + { + return Results.Problem( + title: "Invalid multipart body", + detail: ex.Message, + statusCode: StatusCodes.Status400BadRequest); + } + + if (form.Files.Count != 1) + { + return Results.Problem( + title: "Exactly one file required", + detail: $"Got {form.Files.Count} file parts; expected 1.", + statusCode: StatusCodes.Status400BadRequest); + } + + var file = form.Files[0]; + + // Read into memory; per-attachment cap is enforced by the store. + using var ms = new MemoryStream(); + await file.CopyToAsync(ms, cancellationToken); + var bytes = ms.ToArray(); + + var stored = store.Put(file.FileName, file.ContentType, bytes); + if (stored == null) + { + // Store rejected: either over per-attachment cap or global cap. + // We can't tell which without more API surface; report the more + // common one (per-attachment) when the file is itself oversized. + logger.LogWarning("Attachment upload rejected: name={Name}, size={Size}", file.FileName, bytes.Length); + if (bytes.Length > 10 * 1024 * 1024) + { + return Results.Problem( + title: "Attachment too large", + detail: "Each upload must be 10 MB or less.", + statusCode: StatusCodes.Status413PayloadTooLarge); + } + return Results.Problem( + title: "Attachment store full", + detail: "Server attachment cache is at capacity. Try again in a few minutes.", + statusCode: StatusCodes.Status507InsufficientStorage); + } + + return Results.Created( + $"/attachments/{stored.Id}", + new UploadResponse(stored.Id, stored.Name, stored.ContentType, bytes.Length, stored.ExpiresAt)); + } + + public sealed record UploadResponse( + string AttachmentId, + string Name, + string? ContentType, + long Size, + DateTimeOffset ExpiresAt); +} diff --git a/src/CalendarMcp.HttpServer/Endpoints/AttachmentEvictionService.cs b/src/CalendarMcp.HttpServer/Endpoints/AttachmentEvictionService.cs new file mode 100644 index 0000000..77b5b90 --- /dev/null +++ b/src/CalendarMcp.HttpServer/Endpoints/AttachmentEvictionService.cs @@ -0,0 +1,39 @@ +using CalendarMcp.Core.Services; + +namespace CalendarMcp.HttpServer.Endpoints; + +/// +/// Periodically removes expired entries from . +/// Without this the store would still reject expired entries on consume, but +/// memory would only be reclaimed when an upload triggered the lazy sweep. +/// +public sealed class AttachmentEvictionService( + IAttachmentStore store, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan SweepInterval = TimeSpan.FromSeconds(60); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Attachment eviction sweeper started, interval {Interval}", SweepInterval); + using var timer = new PeriodicTimer(SweepInterval); + try + { + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + store.EvictExpired(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Attachment eviction sweep failed; will retry"); + } + } + } + catch (OperationCanceledException) + { + // shutdown + } + } +} diff --git a/src/CalendarMcp.HttpServer/Program.cs b/src/CalendarMcp.HttpServer/Program.cs index 35800b0..9a5beeb 100644 --- a/src/CalendarMcp.HttpServer/Program.cs +++ b/src/CalendarMcp.HttpServer/Program.cs @@ -3,6 +3,7 @@ using CalendarMcp.Core.Configuration; using CalendarMcp.HttpServer.Admin; using CalendarMcp.HttpServer.BlazorAdmin; +using CalendarMcp.HttpServer.Endpoints; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.HttpOverrides; @@ -101,6 +102,10 @@ public static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Background sweeper for the attachment store (uploads land here only + // in HTTP mode, so eviction is HTTP-side too). + builder.Services.AddHostedService(); + // OpenAPI builder.Services.AddOpenApi(); @@ -203,6 +208,10 @@ public static void Main(string[] args) // Map MCP protocol endpoints (HTTP/SSE) app.MapMcp(); + // Map attachment upload endpoint (sibling of /mcp; same network-level + // protection — Tailscale ACLs / reverse proxy). + app.MapAttachmentEndpoints(); + // Map admin API endpoints app.MapAdminEndpoints(); diff --git a/src/CalendarMcp.Tests/Helpers/TestAttachmentStore.cs b/src/CalendarMcp.Tests/Helpers/TestAttachmentStore.cs new file mode 100644 index 0000000..e11785f --- /dev/null +++ b/src/CalendarMcp.Tests/Helpers/TestAttachmentStore.cs @@ -0,0 +1,49 @@ +using CalendarMcp.Core.Services; + +namespace CalendarMcp.Tests.Helpers; + +/// +/// Minimal in-memory for tests. Lets tests +/// pre-populate entries with and asserts via +/// . +/// +public sealed class TestAttachmentStore : IAttachmentStore +{ + private readonly Dictionary _entries = new(StringComparer.Ordinal); + public List ConsumedIds { get; } = new(); + + public StoredAttachment Seed( + string id, + string name, + byte[] bytes, + string? contentType = null, + DateTimeOffset? expiresAt = null) + { + var entry = new StoredAttachment + { + Id = id, + Name = name, + ContentType = contentType, + Bytes = bytes, + ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddMinutes(15), + }; + _entries[id] = entry; + return entry; + } + + public StoredAttachment? Put(string name, string? contentType, byte[] bytes) + { + // Tests don't go through the upload path; force them to use Seed. + throw new NotImplementedException("Use Seed in tests."); + } + + public StoredAttachment? TryConsume(string attachmentId) + { + ConsumedIds.Add(attachmentId); + if (!_entries.Remove(attachmentId, out var entry)) + return null; + return entry.ExpiresAt <= DateTimeOffset.UtcNow ? null : entry; + } + + public void EvictExpired() { /* no-op for tests */ } +} diff --git a/src/CalendarMcp.Tests/Services/InMemoryAttachmentStoreTests.cs b/src/CalendarMcp.Tests/Services/InMemoryAttachmentStoreTests.cs new file mode 100644 index 0000000..d86b15c --- /dev/null +++ b/src/CalendarMcp.Tests/Services/InMemoryAttachmentStoreTests.cs @@ -0,0 +1,111 @@ +using CalendarMcp.Core.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace CalendarMcp.Tests.Services; + +[TestClass] +public class InMemoryAttachmentStoreTests +{ + private static InMemoryAttachmentStore CreateStore( + AttachmentStoreOptions? options = null, + TimeProvider? time = null) + { + return new InMemoryAttachmentStore( + Options.Create(options ?? new AttachmentStoreOptions()), + NullLogger.Instance, + time); + } + + [TestMethod] + public void Put_Then_TryConsume_RoundTripsBytes() + { + var store = CreateStore(); + var data = "hello"u8.ToArray(); + + var stored = store.Put("hello.txt", "text/plain", data); + + Assert.IsNotNull(stored); + Assert.IsFalse(string.IsNullOrEmpty(stored!.Id)); + Assert.AreEqual("hello.txt", stored.Name); + Assert.AreEqual("text/plain", stored.ContentType); + CollectionAssert.AreEqual(data, stored.Bytes); + + var consumed = store.TryConsume(stored.Id); + Assert.IsNotNull(consumed); + CollectionAssert.AreEqual(data, consumed!.Bytes); + + // Single-use: a second consume returns null. + Assert.IsNull(store.TryConsume(stored.Id)); + } + + [TestMethod] + public void Put_RejectsOversizedFile() + { + var store = CreateStore(new AttachmentStoreOptions { MaxBytesPerAttachment = 100 }); + var stored = store.Put("big.bin", null, new byte[101]); + Assert.IsNull(stored); + } + + [TestMethod] + public void Put_RejectsWhenGlobalCapWouldBeExceeded() + { + var store = CreateStore(new AttachmentStoreOptions + { + MaxBytesPerAttachment = 1000, + MaxTotalBytes = 100, + }); + + Assert.IsNotNull(store.Put("a", null, new byte[60])); + // Next 60 bytes would put us at 120, over the 100-byte global cap. + Assert.IsNull(store.Put("b", null, new byte[60])); + } + + [TestMethod] + public void TryConsume_ExpiredEntry_ReturnsNull() + { + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var store = CreateStore( + new AttachmentStoreOptions { Ttl = TimeSpan.FromMinutes(1) }, + time); + + var stored = store.Put("x", null, new byte[10]); + Assert.IsNotNull(stored); + + time.Advance(TimeSpan.FromMinutes(2)); + + var consumed = store.TryConsume(stored!.Id); + Assert.IsNull(consumed); + } + + [TestMethod] + public void EvictExpired_RemovesPastEntriesAndFreesQuota() + { + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var store = CreateStore( + new AttachmentStoreOptions + { + Ttl = TimeSpan.FromMinutes(1), + MaxTotalBytes = 100, + MaxBytesPerAttachment = 100, + }, + time); + + var first = store.Put("a", null, new byte[80]); + Assert.IsNotNull(first); + + time.Advance(TimeSpan.FromMinutes(2)); + store.EvictExpired(); + + // Quota should be reclaimed: a fresh 80-byte upload now fits. + var second = store.Put("b", null, new byte[80]); + Assert.IsNotNull(second); + } + + private sealed class FakeTimeProvider(DateTimeOffset start) : TimeProvider + { + private DateTimeOffset _now = start; + public override DateTimeOffset GetUtcNow() => _now; + public void Advance(TimeSpan by) => _now = _now.Add(by); + } +} diff --git a/src/CalendarMcp.Tests/Tools/SendEmailToolAttachmentIdTests.cs b/src/CalendarMcp.Tests/Tools/SendEmailToolAttachmentIdTests.cs new file mode 100644 index 0000000..0d96b57 --- /dev/null +++ b/src/CalendarMcp.Tests/Tools/SendEmailToolAttachmentIdTests.cs @@ -0,0 +1,184 @@ +using System.Text.Json; +using CalendarMcp.Core.Models; +using CalendarMcp.Core.Services; +using CalendarMcp.Core.Tools; +using CalendarMcp.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Rocks; + +namespace CalendarMcp.Tests.Tools; + +[TestClass] +public class SendEmailToolAttachmentIdTests +{ + [TestMethod] + public async Task SendEmail_ResolvesAttachmentIdToBytes_AndConsumesEntry() + { + var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365"); + var bytes = "PDF-content"u8.ToArray(); + var store = new TestAttachmentStore(); + store.Seed("upload-id-1", "report.pdf", bytes, "application/pdf"); + + IReadOnlyList? captured = null; + var (regExp, factExp, provExp) = WireProvider(account, atts => captured = atts); + + var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), store, + NullLogger.Instance); + + var result = await tool.SendEmail( + new List { "to@example.com" }, "Subject", "Body", "acc-1", + attachments: new List + { + new() { AttachmentId = "upload-id-1" }, + }); + + Assert.IsTrue(JsonDocument.Parse(result).RootElement.GetProperty("success").GetBoolean()); + Assert.IsNotNull(captured); + Assert.AreEqual(1, captured!.Count); + Assert.AreEqual("report.pdf", captured[0].Name); + Assert.AreEqual("application/pdf", captured[0].ContentType); + CollectionAssert.AreEqual(bytes, Convert.FromBase64String(captured[0].Base64Content!)); + + // Single-use: store consumed the entry. + CollectionAssert.AreEqual(new[] { "upload-id-1" }, store.ConsumedIds); + + regExp.Verify(); + factExp.Verify(); + provExp.Verify(); + } + + [TestMethod] + public async Task SendEmail_InlineNameAndContentTypeOverrideUploadMetadata() + { + var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365"); + var store = new TestAttachmentStore(); + store.Seed("u1", "uploaded.bin", new byte[] { 1, 2, 3 }, "application/octet-stream"); + + IReadOnlyList? captured = null; + var (regExp, factExp, provExp) = WireProvider(account, atts => captured = atts); + + var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), store, + NullLogger.Instance); + + await tool.SendEmail( + new List { "to@example.com" }, "Subject", "Body", "acc-1", + attachments: new List + { + new() + { + AttachmentId = "u1", + Name = "renamed.dat", + ContentType = "application/x-custom", + }, + }); + + Assert.IsNotNull(captured); + Assert.AreEqual("renamed.dat", captured![0].Name); + Assert.AreEqual("application/x-custom", captured[0].ContentType); + } + + [TestMethod] + public async Task SendEmail_UnknownAttachmentId_ReturnsError() + { + var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365"); + + var regExp = new IAccountRegistryCreateExpectations(); + regExp.Setups.GetAccountAsync("acc-1") + .ReturnValue(Task.FromResult(account)); + + var factExp = new IProviderServiceFactoryCreateExpectations(); + // Provider should NOT be called. + + var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), + new TestAttachmentStore(), NullLogger.Instance); + + var result = await tool.SendEmail( + new List { "to@example.com" }, "Subject", "Body", "acc-1", + attachments: new List + { + new() { AttachmentId = "does-not-exist" }, + }); + + var error = JsonDocument.Parse(result).RootElement.GetProperty("error").GetString(); + Assert.IsTrue(error?.Contains("does-not-exist", StringComparison.Ordinal) ?? false); + Assert.IsTrue(error?.Contains("unknown or expired", StringComparison.Ordinal) ?? false); + } + + [TestMethod] + public async Task SendEmail_BothInlineAndId_ReturnsError_AndDoesNotConsume() + { + var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365"); + var store = new TestAttachmentStore(); + store.Seed("u1", "x.bin", new byte[] { 1 }); + + var regExp = new IAccountRegistryCreateExpectations(); + var factExp = new IProviderServiceFactoryCreateExpectations(); + + var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), store, + NullLogger.Instance); + + var result = await tool.SendEmail( + new List { "to@example.com" }, "Subject", "Body", "acc-1", + attachments: new List + { + new() { AttachmentId = "u1", Name = "x.bin", Base64Content = "AQ==" }, + }); + + var error = JsonDocument.Parse(result).RootElement.GetProperty("error").GetString(); + Assert.IsTrue(error?.Contains("not both", StringComparison.OrdinalIgnoreCase) ?? false); + + // Store entry must NOT have been consumed — shape errors precede consumption. + Assert.AreEqual(0, store.ConsumedIds.Count); + } + + [TestMethod] + public async Task SendEmail_NeitherInlineNorId_ReturnsError() + { + var regExp = new IAccountRegistryCreateExpectations(); + var factExp = new IProviderServiceFactoryCreateExpectations(); + var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), + new TestAttachmentStore(), NullLogger.Instance); + + var result = await tool.SendEmail( + new List { "to@example.com" }, "Subject", "Body", "acc-1", + attachments: new List + { + new() { Name = "x.bin" }, + }); + + var error = JsonDocument.Parse(result).RootElement.GetProperty("error").GetString(); + Assert.IsTrue(error?.Contains("base64Content", StringComparison.Ordinal) ?? false); + Assert.IsTrue(error?.Contains("attachmentId", StringComparison.Ordinal) ?? false); + } + + private static ( + IAccountRegistryCreateExpectations reg, + IProviderServiceFactoryCreateExpectations fact, + IProviderServiceCreateExpectations prov) WireProvider( + AccountInfo account, + Action?> capture) + { + var regExp = new IAccountRegistryCreateExpectations(); + regExp.Setups.GetAccountAsync(account.Id) + .ReturnValue(Task.FromResult(account)); + + var provExp = new IProviderServiceCreateExpectations(); + provExp.Setups.SendEmailAsync( + account.Id, Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>(), + Arg.Any?>(), + Arg.Any()) + .Callback((string _, string _, string _, string _, string _, + List? _, IReadOnlyList? atts, + CancellationToken _) => + { + capture(atts); + return Task.FromResult("msg-1"); + }); + + var factExp = new IProviderServiceFactoryCreateExpectations(); + factExp.Setups.GetProvider(account.Provider).ReturnValue(provExp.Instance()); + + return (regExp, factExp, provExp); + } +} diff --git a/src/CalendarMcp.Tests/Tools/SendEmailToolMcpInvocationTests.cs b/src/CalendarMcp.Tests/Tools/SendEmailToolMcpInvocationTests.cs new file mode 100644 index 0000000..56e0a5d --- /dev/null +++ b/src/CalendarMcp.Tests/Tools/SendEmailToolMcpInvocationTests.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using CalendarMcp.Core.Models; +using CalendarMcp.Core.Services; +using CalendarMcp.Core.Tools; +using CalendarMcp.Tests.Helpers; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Rocks; + +namespace CalendarMcp.Tests.Tools; + +/// +/// Verifies that the MCP SDK's AIFunctionFactory can deserialize a JSON +/// argument payload containing a complex attachment array into the tool +/// method's parameter. Direct C# calls +/// in bypass that path; this test exercises +/// the same wiring used at runtime by McpServerTool.Create + InvokeAsync. +/// +[TestClass] +public class SendEmailToolMcpInvocationTests +{ + [TestMethod] + public async Task SendEmail_InvokedThroughMcpFactory_DeserializesAttachmentJson() + { + var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365"); + + var regExp = new IAccountRegistryCreateExpectations(); + regExp.Setups.GetAccountAsync("acc-1") + .ReturnValue(Task.FromResult(account)); + + IReadOnlyList? capturedAttachments = null; + + var provExp = new IProviderServiceCreateExpectations(); + provExp.Setups.SendEmailAsync( + "acc-1", "to@example.com", "Subject", "Body", Arg.Any(), + Arg.Any?>(), Arg.Any?>(), + Arg.Any()) + .Callback((string _, string _, string _, string _, string _, + List? _, IReadOnlyList? atts, + CancellationToken _) => + { + capturedAttachments = atts; + return Task.FromResult("msg-123"); + }); + + var factExp = new IProviderServiceFactoryCreateExpectations(); + factExp.Setups.GetProvider("microsoft365").ReturnValue(provExp.Instance()); + + var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), + new TestAttachmentStore(), NullLogger.Instance); + + // Build an AIFunction the same way the MCP SDK does internally + // (McpServerTool wraps AIFunctionFactory.Create). + var method = typeof(SendEmailTool).GetMethod(nameof(SendEmailTool.SendEmail))!; + var aiFunction = AIFunctionFactory.Create(method, tool); + + // Compose the JSON arguments an MCP client would send. + var argumentsJson = """ + { + "to": ["to@example.com"], + "subject": "Subject", + "body": "Body", + "accountId": "acc-1", + "attachments": [ + { + "name": "hello.txt", + "contentType": "text/plain", + "base64Content": "aGVsbG8=" + } + ] + } + """; + + var args = JsonSerializer.Deserialize>(argumentsJson)!; + var aiArgs = new AIFunctionArguments(); + foreach (var kv in args) + aiArgs[kv.Key] = kv.Value; + + var result = await aiFunction.InvokeAsync(aiArgs, CancellationToken.None); + + Assert.IsNotNull(result, "AIFunction returned null"); + + // The provider should have received exactly one attachment with the + // decoded payload that came in over JSON. + Assert.IsNotNull(capturedAttachments, "Provider was never called"); + Assert.AreEqual(1, capturedAttachments!.Count); + Assert.AreEqual("hello.txt", capturedAttachments[0].Name); + Assert.AreEqual("text/plain", capturedAttachments[0].ContentType); + Assert.AreEqual("aGVsbG8=", capturedAttachments[0].Base64Content); + + regExp.Verify(); + factExp.Verify(); + provExp.Verify(); + } +} diff --git a/src/CalendarMcp.Tests/Tools/SendEmailToolTests.cs b/src/CalendarMcp.Tests/Tools/SendEmailToolTests.cs index 99c12bf..ce7ae45 100644 --- a/src/CalendarMcp.Tests/Tools/SendEmailToolTests.cs +++ b/src/CalendarMcp.Tests/Tools/SendEmailToolTests.cs @@ -31,7 +31,7 @@ public async Task SendEmail_SpecificAccount_Success() factExp.Setups.GetProvider("microsoft365").ReturnValue(provExp.Instance()); var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), - NullLogger.Instance); + new TestAttachmentStore(), NullLogger.Instance); var result = await tool.SendEmail(new List { "to@example.com" }, "Subject", "Body", "acc-1"); var doc = JsonDocument.Parse(result); @@ -53,7 +53,7 @@ public async Task SendEmail_AccountNotFound_ReturnsError() var factExp = new IProviderServiceFactoryCreateExpectations(); var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), - NullLogger.Instance); + new TestAttachmentStore(), NullLogger.Instance); var result = await tool.SendEmail(new List { "to@example.com" }, "Subject", "Body", "nonexistent"); var doc = JsonDocument.Parse(result); @@ -71,7 +71,7 @@ public async Task SendEmail_NoAccountNoMatch_ReturnsError() var factExp = new IProviderServiceFactoryCreateExpectations(); var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), - NullLogger.Instance); + new TestAttachmentStore(), NullLogger.Instance); var result = await tool.SendEmail(new List { "to@unknown.com" }, "Subject", "Body"); var doc = JsonDocument.Parse(result); @@ -86,7 +86,7 @@ public async Task SendEmail_EmptyToList_ReturnsError() var regExp = new IAccountRegistryCreateExpectations(); var factExp = new IProviderServiceFactoryCreateExpectations(); var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), - NullLogger.Instance); + new TestAttachmentStore(), NullLogger.Instance); var result = await tool.SendEmail([], "Subject", "Body", "acc-1"); var doc = JsonDocument.Parse(result); @@ -115,7 +115,7 @@ public async Task SendEmail_WithAttachment_PassesThroughToProvider() factExp.Setups.GetProvider("microsoft365").ReturnValue(provExp.Instance()); var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), - NullLogger.Instance); + new TestAttachmentStore(), NullLogger.Instance); var attachment = new OutboundEmailAttachment { @@ -142,7 +142,7 @@ public async Task SendEmail_AttachmentMissingName_ReturnsError() var regExp = new IAccountRegistryCreateExpectations(); var factExp = new IProviderServiceFactoryCreateExpectations(); var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), - NullLogger.Instance); + new TestAttachmentStore(), NullLogger.Instance); var result = await tool.SendEmail( new List { "to@example.com" }, "Subject", "Body", "acc-1", @@ -162,7 +162,7 @@ public async Task SendEmail_AttachmentMissingContent_ReturnsError() var regExp = new IAccountRegistryCreateExpectations(); var factExp = new IProviderServiceFactoryCreateExpectations(); var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), - NullLogger.Instance); + new TestAttachmentStore(), NullLogger.Instance); var result = await tool.SendEmail( new List { "to@example.com" }, "Subject", "Body", "acc-1", @@ -182,7 +182,7 @@ public async Task SendEmail_AttachmentExceedsTotalCap_ReturnsError() var regExp = new IAccountRegistryCreateExpectations(); var factExp = new IProviderServiceFactoryCreateExpectations(); var tool = new SendEmailTool(regExp.Instance(), factExp.Instance(), - NullLogger.Instance); + new TestAttachmentStore(), NullLogger.Instance); // 35 MB of base64 chars decodes to ~26 MB, just over the 25 MB cap. var bigBase64 = new string('A', 35 * 1024 * 1024); From 184f110c0996226c19fdca0f1d7effd107c5a424 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 3 May 2026 22:44:27 -0500 Subject: [PATCH 6/8] Add inbound attachment visibility and fetch get_email_details now populates attachments[] with name, size, contentType, and a provider-side attachmentId for each file. New get_email_attachment tool fetches the bytes by ID: - stash mode (default): server downloads and stores in IAttachmentStore; returns a store ID the agent passes to send_email. Bytes never round trip through the LLM. Right shape for the forward use case. - inline mode: returns base64 directly, capped at 1 MB so the JSON tool result stays small. For when the agent itself needs to read the file. Provider implementations: - M365 / Outlook.com: Graph attachments collection (metadata-only $select on details, full FileAttachment fetch for content). - Gmail: walks message.payload.parts for filename-bearing parts; uses body.attachmentId for the fetch. - IMAP: positional synthetic IDs (part-N) backed by MailKit re-fetch and decode. - ICS / JSON: return null (no email attachments). Bumps VersionPrefix to 1.2.4. Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Build.props | 2 +- .../2026-05-03-inbound-attachment-fetch.md | 115 ++++++++++++++ docs/mcp-tools.md | 47 +++++- .../ServiceCollectionExtensions.cs | 1 + src/CalendarMcp.Core/Models/EmailMessage.cs | 23 ++- .../Providers/GoogleProviderService.cs | 105 +++++++++++++ .../Providers/IcsProviderService.cs | 5 + .../Providers/ImapProviderService.cs | 54 ++++++- .../Providers/JsonCalendarProviderService.cs | 5 + .../Providers/M365ProviderService.cs | 77 ++++++++++ .../Providers/OutlookComProviderService.cs | 70 +++++++++ .../Services/IProviderService.cs | 15 +- .../Tools/GetEmailAttachmentTool.cs | 107 +++++++++++++ .../Tools/GetEmailDetailsTool.cs | 3 +- src/CalendarMcp.HttpServer/Program.cs | 1 + src/CalendarMcp.StdioServer/Program.cs | 1 + .../Tools/GetEmailAttachmentToolTests.cs | 140 ++++++++++++++++++ 17 files changed, 763 insertions(+), 8 deletions(-) create mode 100644 changelogs/2026-05-03-inbound-attachment-fetch.md create mode 100644 src/CalendarMcp.Core/Tools/GetEmailAttachmentTool.cs create mode 100644 src/CalendarMcp.Tests/Tools/GetEmailAttachmentToolTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index e62eab6..bd039fc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 1.2.3 + 1.2.4 diff --git a/changelogs/2026-05-03-inbound-attachment-fetch.md b/changelogs/2026-05-03-inbound-attachment-fetch.md new file mode 100644 index 0000000..cc690dd --- /dev/null +++ b/changelogs/2026-05-03-inbound-attachment-fetch.md @@ -0,0 +1,115 @@ +# 2026-05-03: Inbound Attachment Visibility + Fetch + +## Summary + +Closes the "I can't even see attachments on inbound mail" gap. Two changes: + +1. `get_email_details` now populates an `attachments[]` array with name, + size, contentType, and a provider-side `attachmentId` for each file. +2. New tool `get_email_attachment` fetches the bytes for one attachment by + that ID. Defaults to **stash** mode — server-side download into the + existing attachment store, returns a store ID the agent can pass to + `send_email` to forward without the bytes ever transiting the LLM. + Optional **inline** mode returns base64 directly, capped at 1 MB. + +Together these enable the dominant inbound workflow ("show me what's +attached / forward this attachment") without violating the same +agent-friendly constraints we set up for the outbound side. + +## Why + +The original `EmailMessage` model had a `HasAttachments: bool` and an +unpopulated `Attachments` list. None of the providers ever filled it in, +and there was no fetch path. Agents could see "yes there's an attachment" +but had no way to do anything about it. + +The most common inbound workflow is "forward this attachment to someone +else." Designing the new tool around that case — server fetches from +inbound provider directly into the attachment store, agent passes the ID +to `send_email`, server hands bytes to outbound provider — keeps the LLM +out of the byte path entirely. The `inline` mode exists for the secondary +case where the agent needs to actually read the content (e.g., extract +text from an inbox PDF). + +## Changes Made + +### Core Library (CalendarMcp.Core) + +#### Model +- `Models/EmailMessage.cs` — + - Added `EmailAttachment.AttachmentId` (provider-side opaque ID). + - New `EmailAttachmentContent` value type (Name + ContentType + Bytes) + returned by the provider fetch method. + +#### Interface +- `Services/IProviderService.cs` — added + `GetEmailAttachmentContentAsync(accountId, emailId, attachmentId, ct)`. + +#### Provider implementations +- `Providers/M365ProviderService.cs` — `GetEmailDetailsAsync` now calls + `Me.Messages[id].Attachments.GetAsync` (metadata only, `$select` excludes + `contentBytes`) and returns each `FileAttachment`'s id/name/size/type. + `GetEmailAttachmentContentAsync` fetches one attachment by Graph id and + returns its `ContentBytes`. +- `Providers/OutlookComProviderService.cs` — same Graph code path as M365. +- `Providers/GoogleProviderService.cs` — walks `message.payload.parts` + recursively in `GetEmailDetailsAsync`, surfaces parts with non-empty + `Filename` using `body.attachmentId` as the ID. The fetch method calls + `Users.Messages.Attachments.Get` and base64url-decodes the body. +- `Providers/ImapProviderService.cs` — uses positional synthetic IDs + (`part-0`, `part-1`, …) since IMAP has no native attachment ID. Fetch + re-opens the folder, re-pulls the message, walks `m.Attachments`, and + decodes the indexed `MimePart` content via MailKit. +- `Providers/IcsProviderService.cs`, `Providers/JsonCalendarProviderService.cs` — + return `null` (no email attachments). + +#### Tool +- `Tools/GetEmailAttachmentTool.cs` — new MCP tool. Defaults to stash mode; + inline mode capped at 1 MB. Returns shape that mirrors `POST /attachments` + response so the same agent code can consume both. +- `Tools/GetEmailDetailsTool.cs` — projection now includes `attachmentId`. + +#### DI +- `Configuration/ServiceCollectionExtensions.cs` — registers + `GetEmailAttachmentTool` as a singleton. + +### Servers +- `CalendarMcp.HttpServer/Program.cs`, + `CalendarMcp.StdioServer/Program.cs` — register the new tool with MCP. + +### Tests +- `Tests/Tools/GetEmailAttachmentToolTests.cs` (5) — stash mode round-trips + bytes through `InMemoryAttachmentStore`; inline mode under cap; inline + over cap rejects with guidance to use stash; provider returns null → + error; invalid mode → error. + +### Docs +- `docs/mcp-tools.md` — `get_email_details` description updated to mention + `attachmentId` returns; full `get_email_attachment` section added with + both modes documented and example responses. + +## Limits + +- **Inline mode cap**: 1 MB. Forces agents toward stash for anything + bigger, keeping the JSON tool result small. +- **Stash mode**: subject to the existing attachment store caps (10 MB + per attachment, 100 MB global, 15 min TTL). +- **Per-attachment fetch is N+1**: each `get_email_attachment` call hits + the provider once. Agents should be selective. If batch fetch becomes a + real workflow, add `get_email_attachments_bulk` later. + +## Known Limitations + +- **IMAP IDs are positional and depend on message structure**: a `part-2` + reference assumes the message body hasn't been re-fetched into a + different shape. In practice the agent calls `get_email_details` then + immediately fetches the bytes; this hasn't been a problem. +- **Graph `ItemAttachment` (forwarded message attachments) and + `ReferenceAttachment` (links) are excluded** from the visible list. Only + `FileAttachment` is supported. Adding these would require a different + fetch path (Graph returns nested `Message` objects for ItemAttachment). +- **No streaming through the agent**. MCP tool results are single + JSON-RPC messages; even SSE transport delivers them as one payload. + Server-internal streaming for the forward case is a future possibility + (would skip the store and pipe inbound → outbound, plus require + outbound chunked upload sessions on Graph). diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index b1c7cc2..308e536 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -171,7 +171,9 @@ Get a contextual, topic-grouped summary of emails across all accounts with perso - **Cross-Account Analysis**: Reveals which topics span multiple accounts #### `get_email_details` -Get full email content including body and attachments. +Get full email content including body and attachment metadata. Each +attachment in the response includes an `attachmentId`; pass it to +`get_email_attachment` to fetch the bytes. **Parameters**: - `accountId`: Specific account ID (required) @@ -193,12 +195,53 @@ Get full email content including body and attachments. { "name": "report.pdf", "size": 524288, - "contentType": "application/pdf" + "contentType": "application/pdf", + "attachmentId": "AAMkAG..." } ] } ``` +#### `get_email_attachment` +Fetch the bytes of one inbound attachment. Two modes: + +- **`stash`** (default): server downloads from the upstream provider, drops + the bytes into the same store used by `POST /attachments`, returns an + `attachmentId` you can pass directly to `send_email`. Bytes never round + trip through the LLM. This is the right mode for forward / re-attach + flows. +- **`inline`**: returns base64 bytes directly. Capped at **1 MB**; larger + attachments must use `stash`. Use only when the agent itself needs to + read the file content. + +**Parameters**: +- `accountId` (required): Account that owns the email. +- `emailId` (required): Email message ID, from `get_email_details`. +- `attachmentId` (required): Provider-side ID, from the `attachments[]` + array on `get_email_details`. +- `mode` (default: `"stash"`): `"stash"` or `"inline"`. + +**Returns** (stash mode): +```json +{ + "attachmentId": "AbCdEf1234...", + "name": "report.pdf", + "contentType": "application/pdf", + "size": 524288, + "expiresAt": "2026-05-03T15:42:00+00:00" +} +``` + +**Returns** (inline mode): +```json +{ + "name": "report.pdf", + "contentType": "application/pdf", + "size": 524288, + "base64Content": "" +} +``` + #### `send_email` Send email from specific account (requires explicit account selection or smart routing). diff --git a/src/CalendarMcp.Core/Configuration/ServiceCollectionExtensions.cs b/src/CalendarMcp.Core/Configuration/ServiceCollectionExtensions.cs index 11f963d..9c34885 100644 --- a/src/CalendarMcp.Core/Configuration/ServiceCollectionExtensions.cs +++ b/src/CalendarMcp.Core/Configuration/ServiceCollectionExtensions.cs @@ -58,6 +58,7 @@ public static IServiceCollection AddCalendarMcpCore(this IServiceCollection serv services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/CalendarMcp.Core/Models/EmailMessage.cs b/src/CalendarMcp.Core/Models/EmailMessage.cs index 8e6c6a8..1f9c8e4 100644 --- a/src/CalendarMcp.Core/Models/EmailMessage.cs +++ b/src/CalendarMcp.Core/Models/EmailMessage.cs @@ -77,11 +77,32 @@ public class EmailMessage } /// -/// Email attachment information +/// Metadata for an attachment on a received email. Bytes are not included; +/// use the get_email_attachment tool with +/// to fetch them. /// public class EmailAttachment { public required string Name { get; init; } public long Size { get; init; } public string ContentType { get; init; } = "application/octet-stream"; + + /// + /// Provider-side identifier for fetching the attachment content. Opaque + /// string scoped to the parent . Format + /// varies by provider (Graph attachment id, Gmail body.attachmentId, + /// IMAP part-N index). + /// + public string? AttachmentId { get; init; } +} + +/// +/// Bytes for one inbound email attachment, as returned by +/// . +/// +public sealed class EmailAttachmentContent +{ + public required string Name { get; init; } + public string? ContentType { get; init; } + public required byte[] Bytes { get; init; } } diff --git a/src/CalendarMcp.Core/Providers/GoogleProviderService.cs b/src/CalendarMcp.Core/Providers/GoogleProviderService.cs index 9b8772e..aa33a71 100644 --- a/src/CalendarMcp.Core/Providers/GoogleProviderService.cs +++ b/src/CalendarMcp.Core/Providers/GoogleProviderService.cs @@ -265,6 +265,11 @@ public async Task> SearchEmailsAsync( } var result = ConvertToEmailMessage(message, accountId, includeBody: true); + // Augment with attachment metadata only on detail fetches. + if (result.HasAttachments && message.Payload != null) + { + CollectGmailAttachments(message.Payload, result.Attachments); + } _logger.LogInformation("Retrieved email details for {EmailId} from Google account {AccountId}", emailId, accountId); return result; } @@ -275,6 +280,106 @@ public async Task> SearchEmailsAsync( } } + private static void CollectGmailAttachments(MessagePart part, List result) + { + // Gmail surfaces an attachment whenever a part has a filename; the + // bytes are fetched separately by Body.AttachmentId. + if (!string.IsNullOrEmpty(part.Filename) && part.Body != null) + { + result.Add(new EmailAttachment + { + Name = part.Filename, + Size = part.Body.Size ?? 0, + ContentType = part.MimeType ?? "application/octet-stream", + AttachmentId = part.Body.AttachmentId, + }); + } + if (part.Parts != null) + { + foreach (var child in part.Parts) + CollectGmailAttachments(child, result); + } + } + + public async Task GetEmailAttachmentContentAsync( + string accountId, + string emailId, + string attachmentId, + CancellationToken cancellationToken = default) + { + var credential = await GetCredentialAsync(accountId, cancellationToken); + if (credential == null) return null; + + try + { + var service = CreateGmailService(credential); + + // Need filename + content-type from the message payload — Gmail's + // attachments.get returns only the body bytes. + var message = await service.Users.Messages.Get("me", emailId).ExecuteAsync(cancellationToken); + string? name = null; + string? contentType = null; + if (message?.Payload != null) + { + FindGmailAttachmentMetadata(message.Payload, attachmentId, ref name, ref contentType); + } + if (name == null) + { + _logger.LogWarning("Attachment {AttachmentId} not found on message {EmailId}", attachmentId, emailId); + return null; + } + + var part = await service.Users.Messages.Attachments.Get("me", emailId, attachmentId) + .ExecuteAsync(cancellationToken); + if (part?.Data == null) + { + return null; + } + + // Body.Data is base64url-encoded. + var b64 = part.Data.Replace('-', '+').Replace('_', '/'); + switch (b64.Length % 4) + { + case 2: b64 += "=="; break; + case 3: b64 += "="; break; + } + var bytes = Convert.FromBase64String(b64); + + return new EmailAttachmentContent + { + Name = name, + ContentType = contentType, + Bytes = bytes, + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching attachment {AttachmentId} on {EmailId} from Google account {AccountId}", + attachmentId, emailId, accountId); + return null; + } + } + + private static void FindGmailAttachmentMetadata( + MessagePart part, string attachmentId, ref string? name, ref string? contentType) + { + if (name != null) return; + if (part.Body?.AttachmentId == attachmentId) + { + name = string.IsNullOrEmpty(part.Filename) ? "attachment" : part.Filename; + contentType = part.MimeType; + return; + } + if (part.Parts != null) + { + foreach (var child in part.Parts) + { + FindGmailAttachmentMetadata(child, attachmentId, ref name, ref contentType); + if (name != null) return; + } + } + } + public async Task SendEmailAsync( string accountId, string to, diff --git a/src/CalendarMcp.Core/Providers/IcsProviderService.cs b/src/CalendarMcp.Core/Providers/IcsProviderService.cs index f490bc8..6c583c9 100644 --- a/src/CalendarMcp.Core/Providers/IcsProviderService.cs +++ b/src/CalendarMcp.Core/Providers/IcsProviderService.cs @@ -263,6 +263,11 @@ public Task> SearchEmailsAsync( CancellationToken cancellationToken = default) => Task.FromResult(null); + public Task GetEmailAttachmentContentAsync( + string accountId, string emailId, string attachmentId, + CancellationToken cancellationToken = default) + => Task.FromResult(null); + public Task SendEmailAsync( string accountId, string to, string subject, string body, string bodyFormat = "html", List? cc = null, diff --git a/src/CalendarMcp.Core/Providers/ImapProviderService.cs b/src/CalendarMcp.Core/Providers/ImapProviderService.cs index 0c482e8..2d6b45f 100644 --- a/src/CalendarMcp.Core/Providers/ImapProviderService.cs +++ b/src/CalendarMcp.Core/Providers/ImapProviderService.cs @@ -271,6 +271,53 @@ public async Task> SearchEmailsAsync( return MessageToEmail(message, folderName, uidValidity, uid, accountId, isRead); } + public async Task GetEmailAttachmentContentAsync( + string accountId, string emailId, string attachmentId, + CancellationToken cancellationToken = default) + { + // IMAP attachment ids are positional: "part-N". + if (!attachmentId.StartsWith("part-", StringComparison.Ordinal) + || !int.TryParse(attachmentId.AsSpan(5), out var index) + || index < 0) + { + _logger.LogWarning("Invalid IMAP attachment id {AttachmentId}", attachmentId); + return null; + } + + var (folderName, uidValidity, uid) = ParseEmailId(emailId); + var cfg = await ResolveConfigAsync(accountId); + + using var client = await OpenImapAsync(cfg, cancellationToken); + var folder = await OpenFolderAsync(client, folderName, FolderAccess.ReadOnly, cancellationToken); + + if (folder.UidValidity != uidValidity) + { + _logger.LogWarning("UIDVALIDITY mismatch fetching attachment for {AccountId} {Folder}", accountId, folderName); + return null; + } + + var message = await folder.GetMessageAsync(new UniqueId(uid), cancellationToken); + if (message is null) return null; + + var parts = message.Attachments.OfType().ToList(); + if (index >= parts.Count) + { + _logger.LogWarning("Attachment index {Index} out of range (have {Count})", index, parts.Count); + return null; + } + + var part = parts[index]; + using var ms = new MemoryStream(); + await part.Content.DecodeToAsync(ms, cancellationToken); + + return new EmailAttachmentContent + { + Name = part.FileName ?? "attachment", + ContentType = part.ContentType?.MimeType, + Bytes = ms.ToArray(), + }; + } + public async Task SendEmailAsync( string accountId, string to, string subject, string body, string bodyFormat = "html", List? cc = null, @@ -492,13 +539,16 @@ private static EmailMessage MessageToEmail( var from = m.From.Mailboxes.FirstOrDefault(); var (body, format) = ExtractBody(m); + // IMAP has no native attachment IDs; use a positional synthetic id. + // Stable for the lifetime of the message structure on the server. var attachments = m.Attachments .OfType() - .Select(p => new EmailAttachment + .Select((p, i) => new EmailAttachment { Name = p.FileName ?? "(unnamed)", Size = p.Content?.Stream?.Length ?? 0, - ContentType = p.ContentType?.MimeType ?? "application/octet-stream" + ContentType = p.ContentType?.MimeType ?? "application/octet-stream", + AttachmentId = $"part-{i}", }) .ToList(); diff --git a/src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs b/src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs index 8d30b26..c1befb9 100644 --- a/src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs +++ b/src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs @@ -417,6 +417,11 @@ public async Task> SearchEmailsAsync( return entry == null ? null : MapToEmailMessage(entry, accountId); } + public Task GetEmailAttachmentContentAsync( + string accountId, string emailId, string attachmentId, + CancellationToken cancellationToken = default) + => Task.FromResult(null); + public Task SendEmailAsync( string accountId, string to, string subject, string body, string bodyFormat = "html", List? cc = null, diff --git a/src/CalendarMcp.Core/Providers/M365ProviderService.cs b/src/CalendarMcp.Core/Providers/M365ProviderService.cs index c2e6f36..2f8f9a8 100644 --- a/src/CalendarMcp.Core/Providers/M365ProviderService.cs +++ b/src/CalendarMcp.Core/Providers/M365ProviderService.cs @@ -254,6 +254,12 @@ public async Task> SearchEmailsAsync( } } + var attachments = new List(); + if (message.HasAttachments == true) + { + attachments = await FetchAttachmentMetadataAsync(graphClient, emailId, cancellationToken); + } + var result = new EmailMessage { Id = message.Id ?? string.Empty, @@ -268,6 +274,7 @@ public async Task> SearchEmailsAsync( ReceivedDateTime = message.ReceivedDateTime?.DateTime ?? DateTime.MinValue, IsRead = message.IsRead ?? false, HasAttachments = message.HasAttachments ?? false, + Attachments = attachments, UnsubscribeInfo = Utilities.UnsubscribeHeaderParser.Parse(listUnsubscribe, listUnsubscribePost) }; @@ -281,6 +288,76 @@ public async Task> SearchEmailsAsync( } } + private static async Task> FetchAttachmentMetadataAsync( + GraphServiceClient graphClient, + string emailId, + CancellationToken cancellationToken) + { + // Metadata only — exclude contentBytes to keep this cheap. + var page = await graphClient.Me.Messages[emailId].Attachments.GetAsync( + config => config.QueryParameters.Select = ["id", "name", "contentType", "size"], + cancellationToken); + + var result = new List(); + if (page?.Value == null) return result; + foreach (var att in page.Value) + { + // ItemAttachments (forwarded messages) and ReferenceAttachments + // (links) are reported here too; we only surface FileAttachments. + if (att is not FileAttachment) + continue; + result.Add(new EmailAttachment + { + Name = att.Name ?? "attachment", + Size = att.Size ?? 0, + ContentType = att.ContentType ?? "application/octet-stream", + AttachmentId = att.Id, + }); + } + return result; + } + + public async Task GetEmailAttachmentContentAsync( + string accountId, + string emailId, + string attachmentId, + CancellationToken cancellationToken = default) + { + var token = await GetAccessTokenAsync(accountId, cancellationToken); + if (token == null) + { + return null; + } + + try + { + var authProvider = new BearerTokenAuthenticationProvider(token); + var graphClient = new GraphServiceClient(authProvider); + + var attachment = await graphClient.Me.Messages[emailId].Attachments[attachmentId] + .GetAsync(cancellationToken: cancellationToken); + + if (attachment is not FileAttachment file || file.ContentBytes == null) + { + _logger.LogWarning("Attachment {AttachmentId} on {EmailId} is not a file attachment", attachmentId, emailId); + return null; + } + + return new EmailAttachmentContent + { + Name = file.Name ?? "attachment", + ContentType = file.ContentType, + Bytes = file.ContentBytes, + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching attachment {AttachmentId} on {EmailId} from M365 account {AccountId}", + attachmentId, emailId, accountId); + return null; + } + } + public async Task SendEmailAsync( string accountId, string to, diff --git a/src/CalendarMcp.Core/Providers/OutlookComProviderService.cs b/src/CalendarMcp.Core/Providers/OutlookComProviderService.cs index c18d479..05b7928 100644 --- a/src/CalendarMcp.Core/Providers/OutlookComProviderService.cs +++ b/src/CalendarMcp.Core/Providers/OutlookComProviderService.cs @@ -246,6 +246,12 @@ public async Task> SearchEmailsAsync( } } + var attachments = new List(); + if (message.HasAttachments == true) + { + attachments = await FetchAttachmentMetadataAsync(graphClient, emailId, cancellationToken); + } + var result = new EmailMessage { Id = message.Id ?? string.Empty, @@ -260,6 +266,7 @@ public async Task> SearchEmailsAsync( ReceivedDateTime = message.ReceivedDateTime?.DateTime ?? DateTime.MinValue, IsRead = message.IsRead ?? false, HasAttachments = message.HasAttachments ?? false, + Attachments = attachments, UnsubscribeInfo = Utilities.UnsubscribeHeaderParser.Parse(listUnsubscribe, listUnsubscribePost) }; @@ -273,6 +280,69 @@ public async Task> SearchEmailsAsync( } } + private static async Task> FetchAttachmentMetadataAsync( + GraphServiceClient graphClient, + string emailId, + CancellationToken cancellationToken) + { + var page = await graphClient.Me.Messages[emailId].Attachments.GetAsync( + config => config.QueryParameters.Select = ["id", "name", "contentType", "size"], + cancellationToken); + + var result = new List(); + if (page?.Value == null) return result; + foreach (var att in page.Value) + { + if (att is not FileAttachment) continue; + result.Add(new EmailAttachment + { + Name = att.Name ?? "attachment", + Size = att.Size ?? 0, + ContentType = att.ContentType ?? "application/octet-stream", + AttachmentId = att.Id, + }); + } + return result; + } + + public async Task GetEmailAttachmentContentAsync( + string accountId, + string emailId, + string attachmentId, + CancellationToken cancellationToken = default) + { + var token = await GetAccessTokenAsync(accountId, cancellationToken); + if (token == null) return null; + + try + { + var authProvider = new BearerTokenAuthenticationProvider(token); + var graphClient = new GraphServiceClient(authProvider); + + var attachment = await graphClient.Me.Messages[emailId].Attachments[attachmentId] + .GetAsync(cancellationToken: cancellationToken); + + if (attachment is not FileAttachment file || file.ContentBytes == null) + { + _logger.LogWarning("Attachment {AttachmentId} on {EmailId} is not a file attachment", attachmentId, emailId); + return null; + } + + return new EmailAttachmentContent + { + Name = file.Name ?? "attachment", + ContentType = file.ContentType, + Bytes = file.ContentBytes, + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching attachment {AttachmentId} on {EmailId} from Outlook.com account {AccountId}", + attachmentId, emailId, accountId); + return null; + } + } + public async Task SendEmailAsync( string accountId, string to, diff --git a/src/CalendarMcp.Core/Services/IProviderService.cs b/src/CalendarMcp.Core/Services/IProviderService.cs index 0c0812a..bb3356c 100644 --- a/src/CalendarMcp.Core/Services/IProviderService.cs +++ b/src/CalendarMcp.Core/Services/IProviderService.cs @@ -23,8 +23,21 @@ Task> SearchEmailsAsync( CancellationToken cancellationToken = default); Task GetEmailDetailsAsync( - string accountId, + string accountId, + string emailId, + CancellationToken cancellationToken = default); + + /// + /// Fetches the raw bytes of one attachment on a received email. + /// is the provider-side ID returned in + /// by + /// . Returns null if the + /// attachment isn't found or access fails. + /// + Task GetEmailAttachmentContentAsync( + string accountId, string emailId, + string attachmentId, CancellationToken cancellationToken = default); Task SendEmailAsync( diff --git a/src/CalendarMcp.Core/Tools/GetEmailAttachmentTool.cs b/src/CalendarMcp.Core/Tools/GetEmailAttachmentTool.cs new file mode 100644 index 0000000..a4feec9 --- /dev/null +++ b/src/CalendarMcp.Core/Tools/GetEmailAttachmentTool.cs @@ -0,0 +1,107 @@ +using System.ComponentModel; +using System.Text.Json; +using CalendarMcp.Core.Services; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace CalendarMcp.Core.Tools; + +/// +/// Fetches the bytes of one inbound email attachment. Defaults to "stash" +/// mode: the server downloads from the upstream provider, drops the bytes +/// into the same store used by the upload endpoint, and returns an +/// attachmentId the agent can pass to send_email. The bytes never +/// transit the LLM in that mode. +/// +[McpServerToolType] +public sealed class GetEmailAttachmentTool( + IAccountRegistry accountRegistry, + IProviderServiceFactory providerFactory, + IAttachmentStore attachmentStore, + ILogger logger) +{ + // Hard cap on inline mode — anything larger forces the agent to use + // stash, which doesn't bloat the JSON tool result. + private const long InlineSizeLimitBytes = 1L * 1024 * 1024; + + [McpServerTool, Description( + "Fetch the bytes of one attachment on a received email. Use 'stash' mode (default) to put the bytes into the server's attachment store and get back an attachmentId you can immediately pass to send_email — bytes never round-trip through the agent. Use 'inline' mode only when the agent itself needs to read the content (e.g., to extract text from a PDF), and only for files under 1 MB. Required: accountId, emailId, attachmentId — get them from get_email_details.")] + public async Task GetEmailAttachment( + [Description("Required. Account that owns the email.")] string accountId, + [Description("Required. Email message ID, from get_email_details.")] string emailId, + [Description("Required. Provider-side attachment ID, from the attachments[] array on get_email_details.")] string attachmentId, + [Description("'stash' (default) returns an attachmentId for use in send_email. 'inline' returns base64Content directly; capped at 1 MB.")] string mode = "stash") + { + if (string.IsNullOrEmpty(accountId)) return Err("accountId is required"); + if (string.IsNullOrEmpty(emailId)) return Err("emailId is required"); + if (string.IsNullOrEmpty(attachmentId)) return Err("attachmentId is required"); + + var normalizedMode = mode?.Trim().ToLowerInvariant() ?? "stash"; + if (normalizedMode is not ("stash" or "inline")) + { + return Err($"mode '{mode}' is invalid; use 'stash' or 'inline'."); + } + + try + { + var account = await accountRegistry.GetAccountAsync(accountId); + if (account == null) return Err($"Account '{accountId}' not found"); + + var provider = providerFactory.GetProvider(account.Provider); + var content = await provider.GetEmailAttachmentContentAsync( + accountId, emailId, attachmentId, CancellationToken.None); + + if (content == null) + { + return Err($"Attachment '{attachmentId}' on email '{emailId}' was not found or could not be fetched."); + } + + if (normalizedMode == "inline") + { + if (content.Bytes.LongLength > InlineSizeLimitBytes) + { + return Err( + $"Attachment is {content.Bytes.LongLength:N0} bytes; inline mode is capped at {InlineSizeLimitBytes:N0} bytes. Re-call with mode='stash' and pass the returned attachmentId to send_email, or extract the bytes by other means."); + } + return JsonSerializer.Serialize(new + { + name = content.Name, + contentType = content.ContentType, + size = content.Bytes.LongLength, + base64Content = Convert.ToBase64String(content.Bytes), + }); + } + + // stash mode + var stored = attachmentStore.Put(content.Name, content.ContentType, content.Bytes); + if (stored == null) + { + return Err( + $"Could not stash the attachment ({content.Bytes.LongLength:N0} bytes); the attachment is over the per-attachment cap or the store is full. Try inline mode if the file is small."); + } + + logger.LogInformation( + "Stashed inbound attachment {AttachmentId} from {EmailId} as {StoreId} ({Size} bytes)", + attachmentId, emailId, stored.Id, stored.Bytes.Length); + + return JsonSerializer.Serialize(new + { + attachmentId = stored.Id, + name = stored.Name, + contentType = stored.ContentType, + size = stored.Bytes.LongLength, + expiresAt = stored.ExpiresAt, + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in get_email_attachment tool"); + return Err("Failed to fetch attachment", ex.Message); + } + } + + private static string Err(string error, string? detail = null) + => detail == null + ? JsonSerializer.Serialize(new { error }) + : JsonSerializer.Serialize(new { error, detail }); +} diff --git a/src/CalendarMcp.Core/Tools/GetEmailDetailsTool.cs b/src/CalendarMcp.Core/Tools/GetEmailDetailsTool.cs index c7e50ec..f8ae77b 100644 --- a/src/CalendarMcp.Core/Tools/GetEmailDetailsTool.cs +++ b/src/CalendarMcp.Core/Tools/GetEmailDetailsTool.cs @@ -79,7 +79,8 @@ public async Task GetEmailDetails( { name = a.Name, size = a.Size, - contentType = a.ContentType + contentType = a.ContentType, + attachmentId = a.AttachmentId, }) }; diff --git a/src/CalendarMcp.HttpServer/Program.cs b/src/CalendarMcp.HttpServer/Program.cs index 9a5beeb..b44f54e 100644 --- a/src/CalendarMcp.HttpServer/Program.cs +++ b/src/CalendarMcp.HttpServer/Program.cs @@ -131,6 +131,7 @@ public static void Main(string[] args) .WithTools() .WithTools() .WithTools() + .WithTools() .WithTools() .WithTools() .WithTools() diff --git a/src/CalendarMcp.StdioServer/Program.cs b/src/CalendarMcp.StdioServer/Program.cs index 8cdc007..c30790d 100644 --- a/src/CalendarMcp.StdioServer/Program.cs +++ b/src/CalendarMcp.StdioServer/Program.cs @@ -117,6 +117,7 @@ public static async Task Main(string[] args) .WithTools() .WithTools() .WithTools() + .WithTools() .WithTools() .WithTools() .WithTools() diff --git a/src/CalendarMcp.Tests/Tools/GetEmailAttachmentToolTests.cs b/src/CalendarMcp.Tests/Tools/GetEmailAttachmentToolTests.cs new file mode 100644 index 0000000..cec74d5 --- /dev/null +++ b/src/CalendarMcp.Tests/Tools/GetEmailAttachmentToolTests.cs @@ -0,0 +1,140 @@ +using System.Text.Json; +using CalendarMcp.Core.Models; +using CalendarMcp.Core.Services; +using CalendarMcp.Core.Tools; +using CalendarMcp.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Rocks; + +namespace CalendarMcp.Tests.Tools; + +[TestClass] +public class GetEmailAttachmentToolTests +{ + [TestMethod] + public async Task StashMode_DefaultsAndStoresInAttachmentStore() + { + var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365"); + var bytes = "PDF-content"u8.ToArray(); + var (regExp, factExp, provExp) = WireProviderForFetch( + account, "msg-1", "graph-att-1", + new EmailAttachmentContent { Name = "report.pdf", ContentType = "application/pdf", Bytes = bytes }); + + var store = new InMemoryAttachmentStore( + Options.Create(new AttachmentStoreOptions()), + NullLogger.Instance); + + var tool = new GetEmailAttachmentTool(regExp.Instance(), factExp.Instance(), store, + NullLogger.Instance); + + var json = await tool.GetEmailAttachment("acc-1", "msg-1", "graph-att-1"); + + var doc = JsonDocument.Parse(json).RootElement; + var newId = doc.GetProperty("attachmentId").GetString()!; + Assert.IsFalse(string.IsNullOrEmpty(newId)); + Assert.AreEqual("report.pdf", doc.GetProperty("name").GetString()); + Assert.AreEqual("application/pdf", doc.GetProperty("contentType").GetString()); + Assert.AreEqual(bytes.Length, doc.GetProperty("size").GetInt64()); + + // The store should now have the bytes under the returned ID, + // ready to feed into send_email. + var stored = store.TryConsume(newId); + Assert.IsNotNull(stored); + CollectionAssert.AreEqual(bytes, stored!.Bytes); + + regExp.Verify(); + factExp.Verify(); + provExp.Verify(); + } + + [TestMethod] + public async Task InlineMode_UnderCap_ReturnsBase64() + { + var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365"); + var bytes = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }; + var (regExp, factExp, _) = WireProviderForFetch( + account, "msg-1", "att-1", + new EmailAttachmentContent { Name = "x.bin", ContentType = "application/octet-stream", Bytes = bytes }); + + var tool = new GetEmailAttachmentTool(regExp.Instance(), factExp.Instance(), + new TestAttachmentStore(), NullLogger.Instance); + + var json = await tool.GetEmailAttachment("acc-1", "msg-1", "att-1", "inline"); + + var doc = JsonDocument.Parse(json).RootElement; + Assert.AreEqual("x.bin", doc.GetProperty("name").GetString()); + CollectionAssert.AreEqual(bytes, + Convert.FromBase64String(doc.GetProperty("base64Content").GetString()!)); + } + + [TestMethod] + public async Task InlineMode_OverCap_ReturnsError() + { + var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365"); + // 2 MB > 1 MB inline cap. + var bytes = new byte[2 * 1024 * 1024]; + var (regExp, factExp, _) = WireProviderForFetch( + account, "msg-1", "att-1", + new EmailAttachmentContent { Name = "big.bin", ContentType = null, Bytes = bytes }); + + var tool = new GetEmailAttachmentTool(regExp.Instance(), factExp.Instance(), + new TestAttachmentStore(), NullLogger.Instance); + + var json = await tool.GetEmailAttachment("acc-1", "msg-1", "att-1", "inline"); + var error = JsonDocument.Parse(json).RootElement.GetProperty("error").GetString(); + Assert.IsTrue(error?.Contains("inline mode is capped", StringComparison.Ordinal) ?? false); + Assert.IsTrue(error?.Contains("stash", StringComparison.Ordinal) ?? false); + } + + [TestMethod] + public async Task ProviderReturnsNull_BubblesAsError() + { + var account = TestData.CreateAccount(id: "acc-1", provider: "microsoft365"); + var (regExp, factExp, _) = WireProviderForFetch(account, "msg-1", "missing", null); + + var tool = new GetEmailAttachmentTool(regExp.Instance(), factExp.Instance(), + new TestAttachmentStore(), NullLogger.Instance); + + var json = await tool.GetEmailAttachment("acc-1", "msg-1", "missing"); + var error = JsonDocument.Parse(json).RootElement.GetProperty("error").GetString(); + Assert.IsTrue(error?.Contains("not found", StringComparison.Ordinal) ?? false); + } + + [TestMethod] + public async Task InvalidMode_ReturnsError() + { + var regExp = new IAccountRegistryCreateExpectations(); + var factExp = new IProviderServiceFactoryCreateExpectations(); + var tool = new GetEmailAttachmentTool(regExp.Instance(), factExp.Instance(), + new TestAttachmentStore(), NullLogger.Instance); + + var json = await tool.GetEmailAttachment("acc-1", "msg-1", "att-1", "weird"); + var error = JsonDocument.Parse(json).RootElement.GetProperty("error").GetString(); + Assert.IsTrue(error?.Contains("invalid", StringComparison.OrdinalIgnoreCase) ?? false); + } + + private static ( + IAccountRegistryCreateExpectations reg, + IProviderServiceFactoryCreateExpectations fact, + IProviderServiceCreateExpectations prov) WireProviderForFetch( + AccountInfo account, + string emailId, + string attachmentId, + EmailAttachmentContent? content) + { + var regExp = new IAccountRegistryCreateExpectations(); + regExp.Setups.GetAccountAsync(account.Id) + .ReturnValue(Task.FromResult(account)); + + var provExp = new IProviderServiceCreateExpectations(); + provExp.Setups.GetEmailAttachmentContentAsync( + account.Id, emailId, attachmentId, Arg.Any()) + .ReturnValue(Task.FromResult(content)); + + var factExp = new IProviderServiceFactoryCreateExpectations(); + factExp.Setups.GetProvider(account.Provider).ReturnValue(provExp.Instance()); + + return (regExp, factExp, provExp); + } +} From f304a0a20025958c7ff7ef820d953bc8e965b89b Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 3 May 2026 23:01:16 -0500 Subject: [PATCH 7/8] Fix Gmail attachment fetch for inline-data attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous Gmail get_email_attachment surfaced Gmail's body.attachmentId to the agent, then re-fetched it via messages.attachments.get. That path fails for small attachments (< 5 KB), where Gmail returns the bytes inline in body.data on the original message and the separate attachmentId either isn't populated or doesn't behave as expected. Switch to positional ids (part-0, part-1, ...) — same scheme as IMAP — and handle both cases on fetch: - inline data in body.data: base64url-decode in place - separate fetch via body.attachmentId: only when body.data is empty Bumps VersionPrefix to 1.2.5. Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Build.props | 2 +- .../Providers/GoogleProviderService.cs | 117 +++++++++++------- 2 files changed, 76 insertions(+), 43 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index bd039fc..31f9c24 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 1.2.4 + 1.2.5 diff --git a/src/CalendarMcp.Core/Providers/GoogleProviderService.cs b/src/CalendarMcp.Core/Providers/GoogleProviderService.cs index aa33a71..5accbbb 100644 --- a/src/CalendarMcp.Core/Providers/GoogleProviderService.cs +++ b/src/CalendarMcp.Core/Providers/GoogleProviderService.cs @@ -268,7 +268,8 @@ public async Task> SearchEmailsAsync( // Augment with attachment metadata only on detail fetches. if (result.HasAttachments && message.Payload != null) { - CollectGmailAttachments(message.Payload, result.Attachments); + var index = 0; + CollectGmailAttachments(message.Payload, result.Attachments, ref index); } _logger.LogInformation("Retrieved email details for {EmailId} from Google account {AccountId}", emailId, accountId); return result; @@ -280,10 +281,13 @@ public async Task> SearchEmailsAsync( } } - private static void CollectGmailAttachments(MessagePart part, List result) + // Gmail surfaces an attachment whenever a part has a filename. We expose + // a positional id (part-0, part-1, ...) rather than Gmail's body.attachmentId + // because (a) small attachments (< 5 KB) come back inline in body.data with + // no separately-fetchable attachmentId, and (b) the positional scheme matches + // the IMAP provider so the agent sees a consistent shape. + private static void CollectGmailAttachments(MessagePart part, List result, ref int index) { - // Gmail surfaces an attachment whenever a part has a filename; the - // bytes are fetched separately by Body.AttachmentId. if (!string.IsNullOrEmpty(part.Filename) && part.Body != null) { result.Add(new EmailAttachment @@ -291,22 +295,49 @@ private static void CollectGmailAttachments(MessagePart part, List GetEmailAttachmentContentAsync( string accountId, string emailId, string attachmentId, CancellationToken cancellationToken = default) { + if (!attachmentId.StartsWith("part-", StringComparison.Ordinal) + || !int.TryParse(attachmentId.AsSpan(5), out var targetIndex) + || targetIndex < 0) + { + _logger.LogWarning("Invalid Gmail attachment id {AttachmentId}; expected 'part-N'", attachmentId); + return null; + } + var credential = await GetCredentialAsync(accountId, cancellationToken); if (credential == null) return null; @@ -314,41 +345,52 @@ private static void CollectGmailAttachments(MessagePart part, List SendEmailAsync( From 106e753cb3e52ab97ce55f41c99edebf45a018a4 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 3 May 2026 23:19:01 -0500 Subject: [PATCH 8/8] Add GET/DELETE on the attachment store + emailId param-name hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /attachments/{id} reads the bytes without consuming the entry — useful when the agent has HTTP access and the file is too large for the 1 MB inline cap on get_email_attachment. DELETE /attachments/{id} lets agents free memory before TTL. Single-use semantics on send_email are unchanged. GET is non-consuming; the entry stays until send_email consumes it, DELETE removes it, or the 15 min TTL fires. Same network-level auth as POST. Also tightens the emailId parameter description on get_email_details and get_email_attachment ('NOT messageId') after observing rockbot fumble it once. Bumps VersionPrefix to 1.2.6. Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Build.props | 2 +- .../2026-05-03-attachment-get-delete.md | 93 +++++++++++++++++++ docs/mcp-tools.md | 34 ++++++- .../Services/IAttachmentStore.cs | 21 ++++- .../Services/InMemoryAttachmentStore.cs | 36 +++++++ .../Tools/GetEmailAttachmentTool.cs | 6 +- .../Tools/GetEmailDetailsTool.cs | 2 +- .../Endpoints/AttachmentEndpoints.cs | 43 +++++++++ .../Helpers/TestAttachmentStore.cs | 10 ++ .../Services/InMemoryAttachmentStoreTests.cs | 55 +++++++++++ 10 files changed, 293 insertions(+), 9 deletions(-) create mode 100644 changelogs/2026-05-03-attachment-get-delete.md diff --git a/Directory.Build.props b/Directory.Build.props index 31f9c24..4343dff 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 1.2.5 + 1.2.6 diff --git a/changelogs/2026-05-03-attachment-get-delete.md b/changelogs/2026-05-03-attachment-get-delete.md new file mode 100644 index 0000000..979321c --- /dev/null +++ b/changelogs/2026-05-03-attachment-get-delete.md @@ -0,0 +1,93 @@ +# 2026-05-03: Attachment GET / DELETE + Param-Name Hint + +## Summary + +Adds two HTTP endpoints on the attachment store and tightens the +`emailId` parameter description so agents stop guessing `messageId`. + +- `GET /attachments/{id}` — read the bytes of a stored attachment + without consuming it. Returns raw bytes with `Content-Type` and + `Content-Disposition`. Useful when the agent has HTTP access and the + attachment exceeds the 1 MB inline cap on `get_email_attachment`. +- `DELETE /attachments/{id}` — explicit cleanup before TTL. +- Tool descriptions for `get_email_details` and `get_email_attachment` + now explicitly call out the parameter name (`emailId`, not `messageId`). + +## Why + +Observed during rockbot integration: after `get_email_attachment(mode=stash)` +returned an `attachmentId`, the agent burned 4 tool calls and a web +search trying to download it via various GET URL shapes +(`/attachments/{id}`, `/attachment/{id}`, `/attachments/download/{id}`, +`/api/attachments/{id}` — all 404). The original design intentionally +omitted GET to keep "bytes never round-trip through the agent" as a +guarantee. But that guarantee was already softened by +`get_email_attachment(mode=inline)`, which returns base64 in the JSON +tool result — so adding a real GET is the same capability over a better +transport (no JSON envelope, no base64 inflation, no 1 MB cap). + +The agent also fumbled `messageId` vs `emailId` once. Description fix is +free. + +## Design + +### Single-use vs. peek + +`send_email`'s consume semantics are unchanged — the ID is removed when +send succeeds, preventing replay confusion. The new GET is **non-consuming**: +it reads without removing, so the same ID can be GET'd multiple times and +then still passed to `send_email`. The entry stays in the store until +`send_email` consumes it, `DELETE` removes it, or TTL fires. + +### Auth + +Same as the existing POST: network-level only (Tailscale ACL on the +public ingress; pod-internal `http://calendar-mcp.calendar-mcp:8080/` +inside the cluster). IDs are 128 random bits, unguessable in practice. + +### Caps + +No new caps. Per-attachment size and global store size are unchanged from +the upload endpoint (10 MB / 100 MB / 15 min TTL). + +## Changes Made + +### Core (CalendarMcp.Core) + +- `Services/IAttachmentStore.cs` — added `TryRead(id)` and `TryDelete(id)` + alongside the existing `TryConsume(id)`. +- `Services/InMemoryAttachmentStore.cs` — implemented both. `TryRead` + also evicts lazily on expiry while it has the lock. + +### HTTP Server (CalendarMcp.HttpServer) + +- `Endpoints/AttachmentEndpoints.cs` — + - `GET /attachments/{id}` returns `Results.File` with the entry's + `Bytes`, `ContentType`, and `Name` for `Content-Disposition`. + - `DELETE /attachments/{id}` returns 204 on success, 404 if already + gone. + +### Tools (description tightening) + +- `Tools/GetEmailDetailsTool.cs`, `Tools/GetEmailAttachmentTool.cs` — + explicit "pass as parameter name 'emailId' (NOT 'messageId')" hint + on the email parameter description. +- `Tools/GetEmailAttachmentTool.cs` — top-level description now mentions + `GET /attachments/{id}` as an HTTP-only escape hatch when the file is + too large for inline mode. + +### Tests (CalendarMcp.Tests) + +- `Services/InMemoryAttachmentStoreTests.cs` (3 new) — + `TryRead` is repeatable and doesn't block consume; `TryDelete` removes + + frees quota; `TryRead` evicts on expiry. +- `Helpers/TestAttachmentStore.cs` — implements the new interface + methods. + +### Docs + +- `docs/mcp-tools.md` — new sections for `GET /attachments/{id}` and + `DELETE /attachments/{id}` with curl recipes; param-name reminder + callout under `get_email_attachment`. + +## Bumps `VersionPrefix` to 1.2.6. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 308e536..73c4f13 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -203,7 +203,11 @@ attachment in the response includes an `attachmentId`; pass it to ``` #### `get_email_attachment` -Fetch the bytes of one inbound attachment. Two modes: +Fetch the bytes of one inbound attachment. Two modes. + +> **Parameter name reminder**: the email parameter is `emailId`, not +> `messageId`. Same convention as `get_email_details`, +> `delete_email`, and friends. - **`stash`** (default): server downloads from the upstream provider, drops the bytes into the same store used by `POST /attachments`, returns an @@ -315,6 +319,34 @@ curl example: curl -F file=@report.pdf https://calendar-mcp.tail920062.ts.net/attachments ``` +#### `GET /attachments/{attachmentId}` + +Reads the bytes of a stored attachment **without consuming it**. Returns +raw bytes with `Content-Type` and `Content-Disposition` headers populated +from the upload metadata. Useful when the agent has HTTP access and the +attachment is too large for `get_email_attachment(mode="inline")`'s 1 MB +cap. + +The entry stays in the store until `send_email` consumes it, +`DELETE /attachments/{id}` removes it, or the 15 min TTL expires. Multiple +GETs against the same ID are fine. + +Returns `404` if the ID is unknown, expired, or already consumed. + +```sh +curl -OJ https://calendar-mcp.tail920062.ts.net/attachments/AbCdEf1234 +``` + +#### `DELETE /attachments/{attachmentId}` + +Removes an attachment from the store before its TTL. Returns `204` on +success, `404` if the entry was already gone. Polite cleanup for agents +that uploaded an attachment they decided not to send. + +```sh +curl -X DELETE https://calendar-mcp.tail920062.ts.net/attachments/AbCdEf1234 +``` + **Returns**: ```json { diff --git a/src/CalendarMcp.Core/Services/IAttachmentStore.cs b/src/CalendarMcp.Core/Services/IAttachmentStore.cs index b04f10e..a8ab4dc 100644 --- a/src/CalendarMcp.Core/Services/IAttachmentStore.cs +++ b/src/CalendarMcp.Core/Services/IAttachmentStore.cs @@ -15,14 +15,29 @@ public interface IAttachmentStore /// /// Atomically removes and returns the entry. Returns null if the - /// ID is unknown or already expired. + /// ID is unknown or already expired. Use this for single-use consumption + /// paths (e.g. send_email). /// StoredAttachment? TryConsume(string attachmentId); + /// + /// Returns the entry without removing it. Returns null if the ID + /// is unknown or expired. The entry remains in the store until consumed, + /// explicitly deleted, or TTL'd out. Use this for the HTTP GET path + /// where the caller may want to read first and consume later. + /// + StoredAttachment? TryRead(string attachmentId); + + /// + /// Removes the entry if present. Returns true if removed, false if it + /// was already gone (or never existed). No-throw. + /// + bool TryDelete(string attachmentId); + /// /// Removes any entries past their absolute expiry. Safe to call from a - /// background sweeper; also rejects expired - /// entries lazily. + /// background sweeper; and + /// also reject expired entries lazily. /// void EvictExpired(); } diff --git a/src/CalendarMcp.Core/Services/InMemoryAttachmentStore.cs b/src/CalendarMcp.Core/Services/InMemoryAttachmentStore.cs index 0a2e214..69fcbea 100644 --- a/src/CalendarMcp.Core/Services/InMemoryAttachmentStore.cs +++ b/src/CalendarMcp.Core/Services/InMemoryAttachmentStore.cs @@ -85,6 +85,42 @@ public InMemoryAttachmentStore( } } + public StoredAttachment? TryRead(string attachmentId) + { + if (string.IsNullOrEmpty(attachmentId)) + return null; + + lock (_gate) + { + if (!_entries.TryGetValue(attachmentId, out var entry)) + return null; + + if (entry.ExpiresAt <= _time.GetUtcNow()) + { + // Lazily clean up the expired entry while we hold the lock. + _entries.Remove(attachmentId); + _totalBytes -= entry.Bytes.Length; + return null; + } + + return entry; + } + } + + public bool TryDelete(string attachmentId) + { + if (string.IsNullOrEmpty(attachmentId)) + return false; + + lock (_gate) + { + if (!_entries.Remove(attachmentId, out var entry)) + return false; + _totalBytes -= entry.Bytes.Length; + return true; + } + } + public void EvictExpired() { lock (_gate) diff --git a/src/CalendarMcp.Core/Tools/GetEmailAttachmentTool.cs b/src/CalendarMcp.Core/Tools/GetEmailAttachmentTool.cs index a4feec9..5115b56 100644 --- a/src/CalendarMcp.Core/Tools/GetEmailAttachmentTool.cs +++ b/src/CalendarMcp.Core/Tools/GetEmailAttachmentTool.cs @@ -25,11 +25,11 @@ public sealed class GetEmailAttachmentTool( private const long InlineSizeLimitBytes = 1L * 1024 * 1024; [McpServerTool, Description( - "Fetch the bytes of one attachment on a received email. Use 'stash' mode (default) to put the bytes into the server's attachment store and get back an attachmentId you can immediately pass to send_email — bytes never round-trip through the agent. Use 'inline' mode only when the agent itself needs to read the content (e.g., to extract text from a PDF), and only for files under 1 MB. Required: accountId, emailId, attachmentId — get them from get_email_details.")] + "Fetch the bytes of one attachment on a received email. Required: accountId, emailId, attachmentId — get them from get_email_details. Modes: 'stash' (default) puts the bytes into the server's attachment store and returns an attachmentId you immediately pass to send_email; bytes never round-trip through the agent. 'inline' returns base64Content directly, capped at 1 MB; use when the agent itself needs to read the content (e.g., to extract text from a PDF). The stash attachmentId is consumed by send_email and is also readable via HTTP GET /attachments/{id} on the HTTP server (returns raw bytes; useful when the file exceeds the 1 MB inline cap and the agent has HTTP access). There is no MCP tool for downloading the stashed bytes — use the HTTP endpoint or just pass the ID to send_email.")] public async Task GetEmailAttachment( [Description("Required. Account that owns the email.")] string accountId, - [Description("Required. Email message ID, from get_email_details.")] string emailId, - [Description("Required. Provider-side attachment ID, from the attachments[] array on get_email_details.")] string attachmentId, + [Description("Required. Email ID — pass as parameter name 'emailId' (NOT 'messageId'). Use the value from the 'id' field on get_email_details / get_emails / search_emails.")] string emailId, + [Description("Required. Provider-side attachment ID, from the attachments[] array on get_email_details (e.g. 'part-0' for Gmail/IMAP, an opaque string for Microsoft Graph).")] string attachmentId, [Description("'stash' (default) returns an attachmentId for use in send_email. 'inline' returns base64Content directly; capped at 1 MB.")] string mode = "stash") { if (string.IsNullOrEmpty(accountId)) return Err("accountId is required"); diff --git a/src/CalendarMcp.Core/Tools/GetEmailDetailsTool.cs b/src/CalendarMcp.Core/Tools/GetEmailDetailsTool.cs index f8ae77b..17cc836 100644 --- a/src/CalendarMcp.Core/Tools/GetEmailDetailsTool.cs +++ b/src/CalendarMcp.Core/Tools/GetEmailDetailsTool.cs @@ -18,7 +18,7 @@ public sealed class GetEmailDetailsTool( [McpServerTool, Description("Get full email content including body and attachments for a specific email. Both accountId and emailId are required — obtain them from the accountId and id fields returned by get_emails or search_emails.")] public async Task GetEmailDetails( [Description("Required. Account ID that owns the email. Obtain from the accountId field returned by get_emails or search_emails.")] string accountId, - [Description("Required. Email message ID. Obtain from the id field returned by get_emails or search_emails.")] string emailId) + [Description("Required. Email ID — pass as parameter name 'emailId' (NOT 'messageId'). Use the value from the 'id' field returned by get_emails or search_emails.")] string emailId) { logger.LogInformation("Getting email details: accountId={AccountId}, emailId={EmailId}", accountId, emailId); diff --git a/src/CalendarMcp.HttpServer/Endpoints/AttachmentEndpoints.cs b/src/CalendarMcp.HttpServer/Endpoints/AttachmentEndpoints.cs index 410ef6f..42457c9 100644 --- a/src/CalendarMcp.HttpServer/Endpoints/AttachmentEndpoints.cs +++ b/src/CalendarMcp.HttpServer/Endpoints/AttachmentEndpoints.cs @@ -18,9 +18,52 @@ public static IEndpointRouteBuilder MapAttachmentEndpoints(this IEndpointRouteBu .ProducesProblem(StatusCodes.Status413PayloadTooLarge) .ProducesProblem(StatusCodes.Status507InsufficientStorage); + group.MapGet("/{attachmentId}", DownloadAsync) + .WithName("DownloadAttachment") + .WithSummary("Read the bytes of a stored attachment without consuming it. Entry remains in the store until send_email consumes it, DELETE removes it, or TTL expires.") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound); + + group.MapDelete("/{attachmentId}", DeleteAsync) + .WithName("DeleteAttachment") + .WithSummary("Remove an attachment from the store before its TTL.") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound); + return routes; } + private static IResult DownloadAsync( + string attachmentId, + IAttachmentStore store) + { + var entry = store.TryRead(attachmentId); + if (entry == null) + { + return Results.Problem( + title: "Attachment not found", + detail: "The attachment ID is unknown, expired, or was already consumed by send_email.", + statusCode: StatusCodes.Status404NotFound); + } + + return Results.File( + entry.Bytes, + contentType: entry.ContentType ?? "application/octet-stream", + fileDownloadName: entry.Name); + } + + private static IResult DeleteAsync( + string attachmentId, + IAttachmentStore store) + { + return store.TryDelete(attachmentId) + ? Results.NoContent() + : Results.Problem( + title: "Attachment not found", + detail: "Nothing to delete; the attachment ID is unknown, expired, or was already consumed.", + statusCode: StatusCodes.Status404NotFound); + } + private static async Task UploadAsync( HttpRequest request, IAttachmentStore store, diff --git a/src/CalendarMcp.Tests/Helpers/TestAttachmentStore.cs b/src/CalendarMcp.Tests/Helpers/TestAttachmentStore.cs index e11785f..56f1c6e 100644 --- a/src/CalendarMcp.Tests/Helpers/TestAttachmentStore.cs +++ b/src/CalendarMcp.Tests/Helpers/TestAttachmentStore.cs @@ -45,5 +45,15 @@ public StoredAttachment Seed( return entry.ExpiresAt <= DateTimeOffset.UtcNow ? null : entry; } + public StoredAttachment? TryRead(string attachmentId) + { + if (!_entries.TryGetValue(attachmentId, out var entry)) + return null; + return entry.ExpiresAt <= DateTimeOffset.UtcNow ? null : entry; + } + + public bool TryDelete(string attachmentId) + => _entries.Remove(attachmentId); + public void EvictExpired() { /* no-op for tests */ } } diff --git a/src/CalendarMcp.Tests/Services/InMemoryAttachmentStoreTests.cs b/src/CalendarMcp.Tests/Services/InMemoryAttachmentStoreTests.cs index d86b15c..27c8761 100644 --- a/src/CalendarMcp.Tests/Services/InMemoryAttachmentStoreTests.cs +++ b/src/CalendarMcp.Tests/Services/InMemoryAttachmentStoreTests.cs @@ -78,6 +78,61 @@ public void TryConsume_ExpiredEntry_ReturnsNull() Assert.IsNull(consumed); } + [TestMethod] + public void TryRead_DoesNotConsume_AndIsRepeatable() + { + var store = CreateStore(); + var stored = store.Put("a.txt", "text/plain", "hello"u8.ToArray()); + Assert.IsNotNull(stored); + + var first = store.TryRead(stored!.Id); + var second = store.TryRead(stored.Id); + Assert.IsNotNull(first); + Assert.IsNotNull(second); + CollectionAssert.AreEqual(first!.Bytes, second!.Bytes); + + // Subsequent consume still works since read didn't remove the entry. + var consumed = store.TryConsume(stored.Id); + Assert.IsNotNull(consumed); + + // After consume, both Read and Consume should miss. + Assert.IsNull(store.TryRead(stored.Id)); + Assert.IsNull(store.TryConsume(stored.Id)); + } + + [TestMethod] + public void TryDelete_RemovesEntry_AndFreesQuota() + { + var store = CreateStore(new AttachmentStoreOptions { MaxTotalBytes = 100 }); + var stored = store.Put("a", null, new byte[80]); + Assert.IsNotNull(stored); + + Assert.IsTrue(store.TryDelete(stored!.Id)); + Assert.IsFalse(store.TryDelete(stored.Id)); // already gone + Assert.IsNull(store.TryRead(stored.Id)); + + // Quota was freed: a fresh 80 B upload now fits where the 80 B + 80 B + // collision would have failed. + Assert.IsNotNull(store.Put("b", null, new byte[80])); + } + + [TestMethod] + public void TryRead_ExpiredEntry_ReturnsNullAndEvicts() + { + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var store = CreateStore( + new AttachmentStoreOptions { Ttl = TimeSpan.FromMinutes(1), MaxTotalBytes = 100 }, + time); + + var stored = store.Put("a", null, new byte[80]); + Assert.IsNotNull(stored); + time.Advance(TimeSpan.FromMinutes(2)); + + Assert.IsNull(store.TryRead(stored!.Id)); + // Read should have evicted lazily; quota is reclaimed. + Assert.IsNotNull(store.Put("b", null, new byte[80])); + } + [TestMethod] public void EvictExpired_RemovesPastEntriesAndFreesQuota() {