From 7dca602829f48ae964368102ad254e83784458da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 22:42:03 +0000 Subject: [PATCH 1/4] Initial plan From 4287694a337be0c41feb1fd67f73fcfb7dcc14de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 23:00:58 +0000 Subject: [PATCH 2/4] Update protocol types to use ReadOnlyMemory for binary data Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../AIContentExtensions.cs | 12 +-- .../Protocol/BlobResourceContents.cs | 37 +++++++- .../Protocol/ContentBlock.cs | 85 +++++++++++++++++-- .../Protocol/ResourceContents.cs | 18 +++- .../Server/AIFunctionMcpServerResource.cs | 4 +- .../Prompts/ConformancePrompts.cs | 2 +- .../Resources/ConformanceResources.cs | 2 +- .../Tools/ConformanceTools.cs | 6 +- .../Program.cs | 4 +- .../Program.cs | 4 +- 10 files changed, 141 insertions(+), 33 deletions(-) diff --git a/src/ModelContextProtocol.Core/AIContentExtensions.cs b/src/ModelContextProtocol.Core/AIContentExtensions.cs index b1ba32bf4..a3ceb808e 100644 --- a/src/ModelContextProtocol.Core/AIContentExtensions.cs +++ b/src/ModelContextProtocol.Core/AIContentExtensions.cs @@ -264,9 +264,9 @@ public static IList ToPromptMessages(this ChatMessage chatMessage { TextContentBlock textContent => new TextContent(textContent.Text), - ImageContentBlock imageContent => new DataContent(Convert.FromBase64String(imageContent.Data), imageContent.MimeType), + ImageContentBlock imageContent => new DataContent(imageContent.DecodedData, imageContent.MimeType), - AudioContentBlock audioContent => new DataContent(Convert.FromBase64String(audioContent.Data), audioContent.MimeType), + AudioContentBlock audioContent => new DataContent(audioContent.DecodedData, audioContent.MimeType), EmbeddedResourceBlock resourceContent => resourceContent.Resource.ToAIContent(), @@ -307,7 +307,7 @@ public static AIContent ToAIContent(this ResourceContents content) AIContent ac = content switch { - BlobResourceContents blobResource => new DataContent(Convert.FromBase64String(blobResource.Blob), blobResource.MimeType ?? "application/octet-stream"), + BlobResourceContents blobResource => new DataContent(blobResource.Data, blobResource.MimeType ?? "application/octet-stream"), TextResourceContents textResource => new TextContent(textResource.Text), _ => throw new NotSupportedException($"Resource type '{content.GetType().Name}' is not supported.") }; @@ -380,13 +380,13 @@ public static ContentBlock ToContentBlock(this AIContent content) DataContent dataContent when dataContent.HasTopLevelMediaType("image") => new ImageContentBlock { - Data = dataContent.Base64Data.ToString(), + Data = System.Text.Encoding.UTF8.GetBytes(dataContent.Base64Data.ToString()), MimeType = dataContent.MediaType, }, DataContent dataContent when dataContent.HasTopLevelMediaType("audio") => new AudioContentBlock { - Data = dataContent.Base64Data.ToString(), + Data = System.Text.Encoding.UTF8.GetBytes(dataContent.Base64Data.ToString()), MimeType = dataContent.MediaType, }, @@ -394,7 +394,7 @@ public static ContentBlock ToContentBlock(this AIContent content) { Resource = new BlobResourceContents { - Blob = dataContent.Base64Data.ToString(), + Blob = System.Text.Encoding.UTF8.GetBytes(dataContent.Base64Data.ToString()), MimeType = dataContent.MediaType, Uri = string.Empty, } diff --git a/src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs b/src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs index dc56daca1..41e1b9e1c 100644 --- a/src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs +++ b/src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs @@ -8,8 +8,8 @@ namespace ModelContextProtocol.Protocol; /// /// /// is used when binary data needs to be exchanged through -/// the Model Context Protocol. The binary data is represented as a base64-encoded string -/// in the property. +/// the Model Context Protocol. The binary data is represented as base64-encoded UTF-8 bytes +/// in the property, providing a zero-copy representation of the wire payload. /// /// /// This class inherits from , which also has a sibling implementation @@ -22,9 +22,38 @@ namespace ModelContextProtocol.Protocol; /// public sealed class BlobResourceContents : ResourceContents { + private byte[]? _decodedData; + /// - /// Gets or sets the base64-encoded string representing the binary data of the item. + /// Gets or sets the base64-encoded UTF-8 bytes representing the binary data of the item. /// + /// + /// This is a zero-copy representation of the wire payload of this item. Setting this value will invalidate any cached value of . + /// [JsonPropertyName("blob")] - public required string Blob { get; set; } + public required ReadOnlyMemory Blob { get; set; } + + /// + /// Gets the decoded data represented by . + /// + /// + /// Accessing this member will decode the value in and cache the result. + /// Subsequent accesses return the cached value unless is modified. + /// + [JsonIgnore] + public ReadOnlyMemory Data + { + get + { + if (_decodedData is null) + { +#if NET6_0_OR_GREATER + _decodedData = Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(Blob.Span)); +#else + _decodedData = Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(Blob.ToArray())); +#endif + } + return _decodedData; + } + } } diff --git a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs index 526e5ad71..e00c0953d 100644 --- a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs +++ b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -87,7 +88,7 @@ public class Converter : JsonConverter string? type = null; string? text = null; string? name = null; - string? data = null; + ReadOnlyMemory? data = null; string? mimeType = null; string? uri = null; string? description = null; @@ -128,7 +129,15 @@ public class Converter : JsonConverter break; case "data": - data = reader.GetString(); + // Read the base64-encoded UTF-8 bytes directly without string allocation + if (reader.HasValueSequence) + { + data = reader.ValueSequence.ToArray(); + } + else + { + data = reader.ValueSpan.ToArray(); + } break; case "mimeType": @@ -279,12 +288,14 @@ public override void Write(Utf8JsonWriter writer, ContentBlock value, JsonSerial break; case ImageContentBlock imageContent: - writer.WriteString("data", imageContent.Data); + // Write the UTF-8 bytes directly as a string value + writer.WriteString("data", imageContent.Data.Span); writer.WriteString("mimeType", imageContent.MimeType); break; case AudioContentBlock audioContent: - writer.WriteString("data", audioContent.Data); + // Write the UTF-8 bytes directly as a string value + writer.WriteString("data", audioContent.Data.Span); writer.WriteString("mimeType", audioContent.MimeType); break; @@ -371,14 +382,43 @@ public sealed class TextContentBlock : ContentBlock /// Represents an image provided to or from an LLM. public sealed class ImageContentBlock : ContentBlock { + private byte[]? _decodedData; + /// public override string Type => "image"; /// - /// Gets or sets the base64-encoded image data. + /// Gets or sets the base64-encoded UTF-8 bytes representing the image data. /// + /// + /// This is a zero-copy representation of the wire payload of this item. Setting this value will invalidate any cached value of . + /// [JsonPropertyName("data")] - public required string Data { get; set; } + public required ReadOnlyMemory Data { get; set; } + + /// + /// Gets the decoded image data represented by . + /// + /// + /// Accessing this member will decode the value in and cache the result. + /// Subsequent accesses return the cached value unless is modified. + /// + [JsonIgnore] + public ReadOnlyMemory DecodedData + { + get + { + if (_decodedData is null) + { +#if NET6_0_OR_GREATER + _decodedData = Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(Data.Span)); +#else + _decodedData = Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(Data.ToArray())); +#endif + } + return _decodedData; + } + } /// /// Gets or sets the MIME type (or "media type") of the content, specifying the format of the data. @@ -393,14 +433,43 @@ public sealed class ImageContentBlock : ContentBlock /// Represents audio provided to or from an LLM. public sealed class AudioContentBlock : ContentBlock { + private byte[]? _decodedData; + /// public override string Type => "audio"; /// - /// Gets or sets the base64-encoded audio data. + /// Gets or sets the base64-encoded UTF-8 bytes representing the audio data. /// + /// + /// This is a zero-copy representation of the wire payload of this item. Setting this value will invalidate any cached value of . + /// [JsonPropertyName("data")] - public required string Data { get; set; } + public required ReadOnlyMemory Data { get; set; } + + /// + /// Gets the decoded audio data represented by . + /// + /// + /// Accessing this member will decode the value in and cache the result. + /// Subsequent accesses return the cached value unless is modified. + /// + [JsonIgnore] + public ReadOnlyMemory DecodedData + { + get + { + if (_decodedData is null) + { +#if NET6_0_OR_GREATER + _decodedData = Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(Data.Span)); +#else + _decodedData = Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(Data.ToArray())); +#endif + } + return _decodedData; + } + } /// /// Gets or sets the MIME type (or "media type") of the content, specifying the format of the data. diff --git a/src/ModelContextProtocol.Core/Protocol/ResourceContents.cs b/src/ModelContextProtocol.Core/Protocol/ResourceContents.cs index 9c295a1f8..3ada14a22 100644 --- a/src/ModelContextProtocol.Core/Protocol/ResourceContents.cs +++ b/src/ModelContextProtocol.Core/Protocol/ResourceContents.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -78,7 +79,7 @@ public class Converter : JsonConverter string? uri = null; string? mimeType = null; - string? blob = null; + ReadOnlyMemory? blob = null; string? text = null; JsonObject? meta = null; @@ -104,7 +105,15 @@ public class Converter : JsonConverter break; case "blob": - blob = reader.GetString(); + // Read the base64-encoded UTF-8 bytes directly without string allocation + if (reader.HasValueSequence) + { + blob = reader.ValueSequence.ToArray(); + } + else + { + blob = reader.ValueSpan.ToArray(); + } break; case "text": @@ -127,7 +136,7 @@ public class Converter : JsonConverter { Uri = uri ?? string.Empty, MimeType = mimeType, - Blob = blob, + Blob = blob.Value, Meta = meta, }; } @@ -162,7 +171,8 @@ public override void Write(Utf8JsonWriter writer, ResourceContents value, JsonSe Debug.Assert(value is BlobResourceContents or TextResourceContents); if (value is BlobResourceContents blobResource) { - writer.WriteString("blob", blobResource.Blob); + // Write the UTF-8 bytes directly as a string value + writer.WriteString("blob", blobResource.Blob.Span); } else if (value is TextResourceContents textResource) { diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs index fcd855de9..c957f54f8 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs @@ -391,7 +391,7 @@ public override async ValueTask ReadAsync( DataContent dc => new() { - Contents = [new BlobResourceContents { Uri = request.Params!.Uri, MimeType = dc.MediaType, Blob = dc.Base64Data.ToString() }], + Contents = [new BlobResourceContents { Uri = request.Params!.Uri, MimeType = dc.MediaType, Blob = System.Text.Encoding.UTF8.GetBytes(dc.Base64Data.ToString()) }], }, string text => new() @@ -420,7 +420,7 @@ public override async ValueTask ReadAsync( { Uri = request.Params!.Uri, MimeType = dc.MediaType, - Blob = dc.Base64Data.ToString() + Blob = System.Text.Encoding.UTF8.GetBytes(dc.Base64Data.ToString()) }, _ => throw new InvalidOperationException($"Unsupported AIContent type '{ac.GetType()}' returned from resource function."), diff --git a/tests/ModelContextProtocol.ConformanceServer/Prompts/ConformancePrompts.cs b/tests/ModelContextProtocol.ConformanceServer/Prompts/ConformancePrompts.cs index 345e215b2..b0b62b979 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Prompts/ConformancePrompts.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Prompts/ConformancePrompts.cs @@ -54,7 +54,7 @@ public static IEnumerable PromptWithImage() Content = new ImageContentBlock { MimeType = "image/png", - Data = TestImageBase64 + Data = System.Text.Encoding.UTF8.GetBytes(TestImageBase64) } }, new PromptMessage diff --git a/tests/ModelContextProtocol.ConformanceServer/Resources/ConformanceResources.cs b/tests/ModelContextProtocol.ConformanceServer/Resources/ConformanceResources.cs index 1e36cb646..1c680e388 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Resources/ConformanceResources.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Resources/ConformanceResources.cs @@ -34,7 +34,7 @@ public static BlobResourceContents StaticBinary() { Uri = "test://static-binary", MimeType = "image/png", - Blob = TestImageBase64 + Blob = System.Text.Encoding.UTF8.GetBytes(TestImageBase64) }; } diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs index 177de5c60..bcbfec596 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs @@ -37,7 +37,7 @@ public static ImageContentBlock ImageContent() { return new ImageContentBlock { - Data = TestImageBase64, + Data = System.Text.Encoding.UTF8.GetBytes(TestImageBase64), MimeType = "image/png" }; } @@ -51,7 +51,7 @@ public static AudioContentBlock AudioContent() { return new AudioContentBlock { - Data = TestAudioBase64, + Data = System.Text.Encoding.UTF8.GetBytes(TestAudioBase64), MimeType = "audio/wav" }; } @@ -84,7 +84,7 @@ public static ContentBlock[] MultipleContentTypes() return [ new TextContentBlock { Text = "Multiple content types test:" }, - new ImageContentBlock { Data = TestImageBase64, MimeType = "image/png" }, + new ImageContentBlock { Data = System.Text.Encoding.UTF8.GetBytes(TestImageBase64), MimeType = "image/png" }, new EmbeddedResourceBlock { Resource = new TextResourceContents diff --git a/tests/ModelContextProtocol.TestServer/Program.cs b/tests/ModelContextProtocol.TestServer/Program.cs index 1321c5f62..cc86470d7 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -280,7 +280,7 @@ private static void ConfigurePrompts(McpServerOptions options) Role = Role.User, Content = new ImageContentBlock { - Data = MCP_TINY_IMAGE, + Data = System.Text.Encoding.UTF8.GetBytes(MCP_TINY_IMAGE), MimeType = "image/png" } }); @@ -354,7 +354,7 @@ private static void ConfigureResources(McpServerOptions options) { Uri = uri, MimeType = "application/octet-stream", - Blob = Convert.ToBase64String(buffer) + Blob = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String(buffer)) }); } } diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index a29c30587..cb0d2c832 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -89,7 +89,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st { Uri = uri, MimeType = "application/octet-stream", - Blob = Convert.ToBase64String(buffer) + Blob = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String(buffer)) }); } } @@ -347,7 +347,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st Role = Role.User, Content = new ImageContentBlock { - Data = MCP_TINY_IMAGE, + Data = System.Text.Encoding.UTF8.GetBytes(MCP_TINY_IMAGE), MimeType = "image/png" } }); From 8e6fcf094b6787f9a64e16fd0d82619a2ea24a25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 23:18:24 +0000 Subject: [PATCH 3/4] Fix test files to work with new binary data representation Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Resources/SimpleResourceType.cs | 2 +- .../Tools/AnnotatedMessageTool.cs | 2 +- .../HttpServerIntegrationTests.cs | 2 +- .../AIContentExtensionsTests.cs | 2 +- .../Client/McpClientTests.cs | 7 ++++--- .../Client/McpClientToolTests.cs | 8 ++++---- .../ClientIntegrationTests.cs | 2 +- .../Protocol/ContentBlockTests.cs | 4 ++-- .../Protocol/CreateMessageResultTests.cs | 2 +- .../Protocol/ResourceContentsTests.cs | 18 +++++++++--------- .../Protocol/UnknownPropertiesTests.cs | 2 +- .../Server/McpServerResourceTests.cs | 8 ++++---- .../Server/McpServerToolTests.cs | 16 ++++++++-------- 13 files changed, 38 insertions(+), 37 deletions(-) diff --git a/samples/EverythingServer/Resources/SimpleResourceType.cs b/samples/EverythingServer/Resources/SimpleResourceType.cs index da185425f..ce5723905 100644 --- a/samples/EverythingServer/Resources/SimpleResourceType.cs +++ b/samples/EverythingServer/Resources/SimpleResourceType.cs @@ -31,7 +31,7 @@ public static ResourceContents TemplateResource(RequestContext AnnotatedMessage(MessageType messageType { contents.Add(new ImageContentBlock { - Data = TinyImageTool.MCP_TINY_IMAGE.Split(",").Last(), + Data = System.Text.Encoding.UTF8.GetBytes(TinyImageTool.MCP_TINY_IMAGE.Split(",").Last()), MimeType = "image/png", Annotations = new() { Audience = [Role.User], Priority = 0.5f } }); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs index ce4f3b56a..0af1bdc68 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs @@ -180,7 +180,7 @@ public async Task ReadResource_Sse_BinaryResource() Assert.Single(result.Contents); BlobResourceContents blobContent = Assert.IsType(result.Contents[0]); - Assert.NotNull(blobContent.Blob); + Assert.False(blobContent.Blob.IsEmpty); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/AIContentExtensionsTests.cs b/tests/ModelContextProtocol.Tests/AIContentExtensionsTests.cs index 3a57a07c6..5f1974fe7 100644 --- a/tests/ModelContextProtocol.Tests/AIContentExtensionsTests.cs +++ b/tests/ModelContextProtocol.Tests/AIContentExtensionsTests.cs @@ -94,7 +94,7 @@ public void ToAIContent_ConvertsToolResultWithMultipleContent() Content = [ new TextContentBlock { Text = "Text result" }, - new ImageContentBlock { Data = Convert.ToBase64String([1, 2, 3]), MimeType = "image/png" } + new ImageContentBlock { Data = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String([1, 2, 3])), MimeType = "image/png" } ] }; diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index 86cefcf10..ab6574bdf 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -163,7 +163,7 @@ public async Task CreateSamplingHandler_ShouldHandleImageMessages() Content = [new ImageContentBlock { MimeType = "image/png", - Data = Convert.ToBase64String(new byte[] { 1, 2, 3 }) + Data = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String(new byte[] { 1, 2, 3 })) }], } ], @@ -196,7 +196,8 @@ public async Task CreateSamplingHandler_ShouldHandleImageMessages() // Assert Assert.NotNull(result); - Assert.Equal(expectedData, result.Content.OfType().FirstOrDefault()?.Data); + var imageData = result.Content.OfType().FirstOrDefault()?.Data.ToArray() ?? []; + Assert.Equal(expectedData, System.Text.Encoding.UTF8.GetString(imageData)); Assert.Equal("test-model", result.Model); Assert.Equal(Role.Assistant, result.Role); Assert.Equal("endTurn", result.StopReason); @@ -211,7 +212,7 @@ public async Task CreateSamplingHandler_ShouldHandleResourceMessages() var mockChatClient = new Mock(); var resource = new BlobResourceContents { - Blob = data, + Blob = System.Text.Encoding.UTF8.GetBytes(data), MimeType = "application/octet-stream", Uri = "data:application/octet-stream" }; diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs index c1b2bcf25..6ce9a1a50 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs @@ -37,13 +37,13 @@ public static TextContentBlock TextOnlyTool() => [McpServerTool] public static ImageContentBlock ImageTool() => new() - { Data = Convert.ToBase64String(Encoding.UTF8.GetBytes("fake-image-data")), MimeType = "image/png" }; + { Data = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String(Encoding.UTF8.GetBytes("fake-image-data"))), MimeType = "image/png" }; // Tool that returns audio content as single ContentBlock [McpServerTool] public static AudioContentBlock AudioTool() => new() - { Data = Convert.ToBase64String(Encoding.UTF8.GetBytes("fake-audio-data")), MimeType = "audio/mp3" }; + { Data = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String(Encoding.UTF8.GetBytes("fake-audio-data"))), MimeType = "audio/mp3" }; // Tool that returns embedded resource [McpServerTool] @@ -103,7 +103,7 @@ public static ResourceLinkBlock ResourceLinkTool() => [McpServerTool] public static IEnumerable MixedWithNonConvertibleTool() { - yield return new ImageContentBlock { Data = Convert.ToBase64String(Encoding.UTF8.GetBytes("image-data")), MimeType = "image/png" }; + yield return new ImageContentBlock { Data = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String(Encoding.UTF8.GetBytes("image-data"))), MimeType = "image/png" }; yield return new ResourceLinkBlock { Uri = "file://linked.txt", Name = "linked.txt" }; } @@ -152,7 +152,7 @@ public static EmbeddedResourceBlock BinaryResourceTool() => Resource = new BlobResourceContents { Uri = "data://blob", - Blob = Convert.ToBase64String(Encoding.UTF8.GetBytes("binary-data")), + Blob = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String(Encoding.UTF8.GetBytes("binary-data"))), MimeType = "application/octet-stream" } }; diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index 018e12dbe..ba95efed6 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -262,7 +262,7 @@ public async Task ReadResource_Stdio_BinaryResource(string clientId) Assert.Single(result.Contents); BlobResourceContents blobResource = Assert.IsType(result.Contents[0]); - Assert.NotNull(blobResource.Blob); + Assert.False(blobResource.Blob.IsEmpty); } // Not supported by "everything" server version on npx diff --git a/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs index 0113b77f3..f901d7e30 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs @@ -183,7 +183,7 @@ public void ToolResultContentBlock_SerializationRoundTrip() Content = [ new TextContentBlock { Text = "Result data" }, - new ImageContentBlock { Data = "base64data", MimeType = "image/png" } + new ImageContentBlock { Data = System.Text.Encoding.UTF8.GetBytes("base64data"), MimeType = "image/png" } ], StructuredContent = JsonElement.Parse("""{"temperature":18,"condition":"cloudy"}"""), IsError = false @@ -198,7 +198,7 @@ public void ToolResultContentBlock_SerializationRoundTrip() var textBlock = Assert.IsType(result.Content[0]); Assert.Equal("Result data", textBlock.Text); var imageBlock = Assert.IsType(result.Content[1]); - Assert.Equal("base64data", imageBlock.Data); + Assert.Equal("base64data", System.Text.Encoding.UTF8.GetString(imageBlock.Data.ToArray())); Assert.Equal("image/png", imageBlock.MimeType); Assert.NotNull(result.StructuredContent); Assert.Equal(18, result.StructuredContent.Value.GetProperty("temperature").GetInt32()); diff --git a/tests/ModelContextProtocol.Tests/Protocol/CreateMessageResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CreateMessageResultTests.cs index 67ab5f4f9..07080f48d 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CreateMessageResultTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CreateMessageResultTests.cs @@ -118,7 +118,7 @@ public void CreateMessageResult_WithImageContent_Serializes() [ new ImageContentBlock { - Data = Convert.ToBase64String([1, 2, 3, 4, 5]), + Data = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String([1, 2, 3, 4, 5])), MimeType = "image/png" } ], diff --git a/tests/ModelContextProtocol.Tests/Protocol/ResourceContentsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ResourceContentsTests.cs index 4f9890f7b..034397065 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ResourceContentsTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ResourceContentsTests.cs @@ -70,7 +70,7 @@ public static void BlobResourceContents_UnknownObjectProperty_IsIgnored() var blobResource = Assert.IsType(result); Assert.Equal("file:///test.bin", blobResource.Uri); Assert.Equal("application/octet-stream", blobResource.MimeType); - Assert.Equal("AQIDBA==", blobResource.Blob); + Assert.Equal("AQIDBA==", System.Text.Encoding.UTF8.GetString(blobResource.Blob.ToArray())); } [Fact] @@ -134,7 +134,7 @@ public static void BlobResourceContents_UnknownNestedArrays_AreIgnored() Assert.NotNull(result); var blobResource = Assert.IsType(result); Assert.Equal("blob://test", blobResource.Uri); - Assert.Equal("SGVsbG8=", blobResource.Blob); + Assert.Equal("SGVsbG8=", System.Text.Encoding.UTF8.GetString(blobResource.Blob.ToArray())); Assert.Equal("application/custom", blobResource.MimeType); } @@ -193,7 +193,7 @@ public static void BlobResourceContents_UnknownArrayOfArrays_IsIgnored() Assert.NotNull(result); var blobResource = Assert.IsType(result); Assert.Equal("http://example.com/blob", blobResource.Uri); - Assert.Equal("Zm9v", blobResource.Blob); + Assert.Equal("Zm9v", System.Text.Encoding.UTF8.GetString(blobResource.Blob.ToArray())); } [Fact] @@ -239,7 +239,7 @@ public static void BlobResourceContents_EmptyUnknownObject_IsIgnored() Assert.NotNull(result); var blobResource = Assert.IsType(result); Assert.Equal("test://blob", blobResource.Uri); - Assert.Equal("YmFy", blobResource.Blob); + Assert.Equal("YmFy", System.Text.Encoding.UTF8.GetString(blobResource.Blob.ToArray())); } [Fact] @@ -301,7 +301,7 @@ public static void BlobResourceContents_VeryDeeplyNestedUnknown_IsIgnored() Assert.NotNull(result); var blobResource = Assert.IsType(result); Assert.Equal("deep://blob", blobResource.Uri); - Assert.Equal("ZGVlcA==", blobResource.Blob); + Assert.Equal("ZGVlcA==", System.Text.Encoding.UTF8.GetString(blobResource.Blob.ToArray())); } [Fact] @@ -363,7 +363,7 @@ public static void BlobResourceContents_SerializationRoundTrip_PreservesKnownPro { Uri = "file:///test.bin", MimeType = "application/octet-stream", - Blob = "AQIDBA==" + Blob = System.Text.Encoding.UTF8.GetBytes("AQIDBA==") }; var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); @@ -373,7 +373,7 @@ public static void BlobResourceContents_SerializationRoundTrip_PreservesKnownPro var blobResource = Assert.IsType(deserialized); Assert.Equal(original.Uri, blobResource.Uri); Assert.Equal(original.MimeType, blobResource.MimeType); - Assert.Equal(original.Blob, blobResource.Blob); + Assert.True(original.Blob.Span.SequenceEqual(blobResource.Blob.Span)); } [Fact] @@ -415,7 +415,7 @@ public static void ResourceContents_WithBothTextAndBlob_PrefersBlob() Assert.NotNull(result); var blobResource = Assert.IsType(result); Assert.Equal("test://both", blobResource.Uri); - Assert.Equal("YmxvYg==", blobResource.Blob); + Assert.Equal("YmxvYg==", System.Text.Encoding.UTF8.GetString(blobResource.Blob.ToArray())); } [Fact] @@ -457,6 +457,6 @@ public static void BlobResourceContents_MissingUri_UsesEmptyString() Assert.NotNull(result); var blobResource = Assert.IsType(result); Assert.Equal(string.Empty, blobResource.Uri); - Assert.Equal("YmxvYg==", blobResource.Blob); + Assert.Equal("YmxvYg==", System.Text.Encoding.UTF8.GetString(blobResource.Blob.ToArray())); } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/UnknownPropertiesTests.cs b/tests/ModelContextProtocol.Tests/Protocol/UnknownPropertiesTests.cs index fd3117de2..1501553d3 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/UnknownPropertiesTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/UnknownPropertiesTests.cs @@ -75,7 +75,7 @@ public void ContentBlock_DeserializationWithMultipleUnknownProperties_SkipsAll() // Assert Assert.NotNull(deserialized); var imageBlock = Assert.IsType(deserialized); - Assert.Equal("base64data", imageBlock.Data); + Assert.Equal("base64data", System.Text.Encoding.UTF8.GetString(imageBlock.Data.ToArray())); Assert.Equal("image/png", imageBlock.MimeType); } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs index 920d41540..19306233c 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs @@ -575,7 +575,7 @@ public async Task CanReturnCollectionOfResourceContents() return (IList) [ new TextResourceContents { Text = "hello", Uri = "" }, - new BlobResourceContents { Blob = Convert.ToBase64String(new byte[] { 1, 2, 3 }), Uri = "" }, + new BlobResourceContents { Blob = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String(new byte[] { 1, 2, 3 })), Uri = "" }, ]; }, new() { Name = "Test" }); var result = await resource.ReadAsync( @@ -584,7 +584,7 @@ public async Task CanReturnCollectionOfResourceContents() Assert.NotNull(result); Assert.Equal(2, result.Contents.Count); Assert.Equal("hello", ((TextResourceContents)result.Contents[0]).Text); - Assert.Equal(Convert.ToBase64String(new byte[] { 1, 2, 3 }), ((BlobResourceContents)result.Contents[1]).Blob); + Assert.Equal(Convert.ToBase64String(new byte[] { 1, 2, 3 }), System.Text.Encoding.UTF8.GetString(((BlobResourceContents)result.Contents[1]).Blob.ToArray())); } [Fact] @@ -636,7 +636,7 @@ public async Task CanReturnDataContent() TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Contents); - Assert.Equal(Convert.ToBase64String(new byte[] { 0, 1, 2 }), ((BlobResourceContents)result.Contents[0]).Blob); + Assert.Equal(Convert.ToBase64String(new byte[] { 0, 1, 2 }), System.Text.Encoding.UTF8.GetString(((BlobResourceContents)result.Contents[0]).Blob.ToArray())); Assert.Equal("application/octet-stream", ((BlobResourceContents)result.Contents[0]).MimeType); } @@ -659,7 +659,7 @@ public async Task CanReturnCollectionOfAIContent() Assert.NotNull(result); Assert.Equal(2, result.Contents.Count); Assert.Equal("hello!", ((TextResourceContents)result.Contents[0]).Text); - Assert.Equal(Convert.ToBase64String(new byte[] { 4, 5, 6 }), ((BlobResourceContents)result.Contents[1]).Blob); + Assert.Equal(Convert.ToBase64String(new byte[] { 4, 5, 6 }), System.Text.Encoding.UTF8.GetString(((BlobResourceContents)result.Contents[1]).Blob.ToArray())); Assert.Equal("application/json", ((BlobResourceContents)result.Contents[1]).MimeType); } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 9ce1dd117..d640684f8 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -270,10 +270,10 @@ public async Task CanReturnCollectionOfAIContent() Assert.Equal("text", (result.Content[0] as TextContentBlock)?.Text); - Assert.Equal("1234", (result.Content[1] as ImageContentBlock)?.Data); + Assert.Equal("1234", System.Text.Encoding.UTF8.GetString((result.Content[1] as ImageContentBlock)?.Data.ToArray() ?? [])); Assert.Equal("image/png", (result.Content[1] as ImageContentBlock)?.MimeType); - Assert.Equal("1234", (result.Content[2] as AudioContentBlock)?.Data); + Assert.Equal("1234", System.Text.Encoding.UTF8.GetString((result.Content[2] as AudioContentBlock)?.Data.ToArray() ?? [])); Assert.Equal("audio/wav", (result.Content[2] as AudioContentBlock)?.MimeType); } @@ -309,12 +309,12 @@ public async Task CanReturnSingleAIContent(string data, string type) } else if (result.Content[0] is ImageContentBlock ic) { - Assert.Equal(data.Split(',').Last(), ic.Data); + Assert.Equal(data.Split(',').Last(), System.Text.Encoding.UTF8.GetString(ic.Data.ToArray())); Assert.Equal("image/png", ic.MimeType); } else if (result.Content[0] is AudioContentBlock ac) { - Assert.Equal(data.Split(',').Last(), ac.Data); + Assert.Equal(data.Split(',').Last(), System.Text.Encoding.UTF8.GetString(ac.Data.ToArray())); Assert.Equal("audio/wav", ac.MimeType); } else @@ -397,7 +397,7 @@ public async Task CanReturnCollectionOfMcpContent() return (IList) [ new TextContentBlock { Text = "42" }, - new ImageContentBlock { Data = "1234", MimeType = "image/png" } + new ImageContentBlock { Data = System.Text.Encoding.UTF8.GetBytes("1234"), MimeType = "image/png" } ]; }); var result = await tool.InvokeAsync( @@ -405,7 +405,7 @@ public async Task CanReturnCollectionOfMcpContent() TestContext.Current.CancellationToken); Assert.Equal(2, result.Content.Count); Assert.Equal("42", Assert.IsType(result.Content[0]).Text); - Assert.Equal("1234", Assert.IsType(result.Content[1]).Data); + Assert.Equal("1234", System.Text.Encoding.UTF8.GetString(Assert.IsType(result.Content[1]).Data.ToArray())); Assert.Equal("image/png", Assert.IsType(result.Content[1]).MimeType); } @@ -414,7 +414,7 @@ public async Task CanReturnCallToolResult() { CallToolResult response = new() { - Content = [new TextContentBlock { Text = "text" }, new ImageContentBlock { Data = "1234", MimeType = "image/png" }] + Content = [new TextContentBlock { Text = "text" }, new ImageContentBlock { Data = System.Text.Encoding.UTF8.GetBytes("1234"), MimeType = "image/png" }] }; Mock mockServer = new(); @@ -431,7 +431,7 @@ public async Task CanReturnCallToolResult() Assert.Equal(2, result.Content.Count); Assert.Equal("text", Assert.IsType(result.Content[0]).Text); - Assert.Equal("1234", Assert.IsType(result.Content[1]).Data); + Assert.Equal("1234", System.Text.Encoding.UTF8.GetString(Assert.IsType(result.Content[1]).Data.ToArray())); } [Fact] From e405dfc2231e7e1b8c6fb811a65f3ae20eee5e26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 23:24:36 +0000 Subject: [PATCH 4/4] Add cache invalidation when Blob/Data properties are set Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Protocol/BlobResourceContents.cs | 11 +++++++++- .../Protocol/ContentBlock.cs | 22 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs b/src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs index 41e1b9e1c..4790df736 100644 --- a/src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs +++ b/src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs @@ -23,6 +23,7 @@ namespace ModelContextProtocol.Protocol; public sealed class BlobResourceContents : ResourceContents { private byte[]? _decodedData; + private ReadOnlyMemory _blob; /// /// Gets or sets the base64-encoded UTF-8 bytes representing the binary data of the item. @@ -31,7 +32,15 @@ public sealed class BlobResourceContents : ResourceContents /// This is a zero-copy representation of the wire payload of this item. Setting this value will invalidate any cached value of . /// [JsonPropertyName("blob")] - public required ReadOnlyMemory Blob { get; set; } + public required ReadOnlyMemory Blob + { + get => _blob; + set + { + _blob = value; + _decodedData = null; // Invalidate cache + } + } /// /// Gets the decoded data represented by . diff --git a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs index e00c0953d..6c003be39 100644 --- a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs +++ b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs @@ -383,6 +383,7 @@ public sealed class TextContentBlock : ContentBlock public sealed class ImageContentBlock : ContentBlock { private byte[]? _decodedData; + private ReadOnlyMemory _data; /// public override string Type => "image"; @@ -394,7 +395,15 @@ public sealed class ImageContentBlock : ContentBlock /// This is a zero-copy representation of the wire payload of this item. Setting this value will invalidate any cached value of . /// [JsonPropertyName("data")] - public required ReadOnlyMemory Data { get; set; } + public required ReadOnlyMemory Data + { + get => _data; + set + { + _data = value; + _decodedData = null; // Invalidate cache + } + } /// /// Gets the decoded image data represented by . @@ -434,6 +443,7 @@ public ReadOnlyMemory DecodedData public sealed class AudioContentBlock : ContentBlock { private byte[]? _decodedData; + private ReadOnlyMemory _data; /// public override string Type => "audio"; @@ -445,7 +455,15 @@ public sealed class AudioContentBlock : ContentBlock /// This is a zero-copy representation of the wire payload of this item. Setting this value will invalidate any cached value of . /// [JsonPropertyName("data")] - public required ReadOnlyMemory Data { get; set; } + public required ReadOnlyMemory Data + { + get => _data; + set + { + _data = value; + _decodedData = null; // Invalidate cache + } + } /// /// Gets the decoded audio data represented by .