Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7dca602
Initial plan
Copilot Dec 5, 2025
4287694
Update protocol types to use ReadOnlyMemory<byte> for binary data
Copilot Dec 5, 2025
8e6fcf0
Fix test files to work with new binary data representation
Copilot Dec 5, 2025
e405dfc
Add cache invalidation when Blob/Data properties are set
Copilot Dec 5, 2025
ebe3eef
Apply performance optimizations using MemoryMarshal.TryGetArray
Copilot Dec 8, 2025
1d76c11
Use GetBytes overload with offset and count parameters
Copilot Dec 8, 2025
39213fb
Use Base64.DecodeFromUtf8 to avoid string intermediate during decoding
Copilot Dec 9, 2025
a4ba4a9
Merge branch 'main' into copilot/fix-binary-data-encoding
ericstj Jan 27, 2026
b4e4eaf
Fix merge conflict with DebuggerDisplay
ericstj Jan 27, 2026
61f0dfa
Cleanup refactoring
ericstj Jan 27, 2026
5de847c
Remove string from DataContent conversion
ericstj Jan 29, 2026
dc01608
Add factory methods to data backed types to efficiently initialize fr…
ericstj Jan 29, 2026
2fc35d9
Update some tests to use factory methods
ericstj Jan 29, 2026
14fedca
Fix test assertions in McpServerToolTests
ericstj Jan 30, 2026
b3eacff
Merge branch 'main' of https://github.com/modelcontextprotocol/csharp…
ericstj Jan 30, 2026
0a3015f
Address code review feedback: rename Data to DecodedData, use span-ba…
Copilot Feb 6, 2026
e4f2816
Address code review: remove zero-copy claims, rename FromImage/FromAu…
Copilot Feb 7, 2026
d72030e
Apply feedback
ericstj Feb 10, 2026
d90737f
Remove ifdef and optimize an encoding method
ericstj Feb 10, 2026
5860b78
Address feedback
ericstj Feb 19, 2026
05e906c
Fix up doc comments
ericstj Feb 19, 2026
a9ad676
Merge branch 'main' of https://github.com/modelcontextprotocol/csharp…
ericstj Feb 19, 2026
51acfd5
Fix a couple new tests
ericstj Feb 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion samples/EverythingServer/Resources/SimpleResourceType.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text;

namespace EverythingServer.Resources;

Expand Down Expand Up @@ -31,7 +32,7 @@ public static ResourceContents TemplateResource(RequestContext<ReadResourceReque
} :
new BlobResourceContents
{
Blob = resource.Description!,
Blob = Encoding.UTF8.GetBytes(resource.Description!),
MimeType = resource.MimeType,
Uri = resource.Uri,
};
Expand Down
3 changes: 2 additions & 1 deletion samples/EverythingServer/Tools/AnnotatedMessageTool.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text;

namespace EverythingServer.Tools;

Expand Down Expand Up @@ -41,7 +42,7 @@ public static IEnumerable<ContentBlock> AnnotatedMessage(MessageType messageType
{
contents.Add(new ImageContentBlock
{
Data = TinyImageTool.MCP_TINY_IMAGE.Split(",").Last(),
Data = Encoding.UTF8.GetBytes(TinyImageTool.MCP_TINY_IMAGE.Split(",").Last()),
MimeType = "image/png",
Annotations = new() { Audience = [Role.User], Priority = 0.5f }
});
Expand Down
59 changes: 59 additions & 0 deletions src/Common/EncodingUtilities.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Buffers;
using System.Buffers.Text;
using System.Diagnostics;
using System.Text;

namespace ModelContextProtocol;

/// <summary>Provides helper methods for encoding operations.</summary>
internal static class EncodingUtilities
{
/// <summary>
/// Converts UTF-16 characters to UTF-8 bytes without intermediate string allocations.
/// </summary>
/// <param name="utf16">The UTF-16 character span to convert.</param>
/// <returns>A byte array containing the UTF-8 encoded bytes.</returns>
public static byte[] GetUtf8Bytes(ReadOnlySpan<char> utf16)
{
byte[] bytes = new byte[Encoding.UTF8.GetByteCount(utf16)];
Encoding.UTF8.GetBytes(utf16, bytes);
return bytes;
}

/// <summary>
/// Encodes binary data to base64-encoded UTF-8 bytes.
/// </summary>
/// <param name="data">The binary data to encode.</param>
/// <returns>A ReadOnlyMemory containing the base64-encoded UTF-8 bytes.</returns>
public static ReadOnlyMemory<byte> EncodeToBase64Utf8(ReadOnlyMemory<byte> data)
{
int maxLength = Base64.GetMaxEncodedToUtf8Length(data.Length);
byte[] buffer = new byte[maxLength];
OperationStatus status = Base64.EncodeToUtf8(data.Span, buffer, out _, out int bytesWritten);
Debug.Assert(status == OperationStatus.Done, "Base64 encoding should succeed for valid input data");
Debug.Assert(bytesWritten == buffer.Length, "Base64 encoding should always produce the same length as the max length");
return buffer.AsMemory(0, bytesWritten);
}

/// <summary>
/// Decodes base64-encoded UTF-8 bytes to binary data.
/// </summary>
/// <param name="base64Data">The base64-encoded UTF-8 bytes to decode.</param>
/// <returns>A ReadOnlyMemory containing the decoded binary data.</returns>
/// <exception cref="FormatException">The input is not valid base64 data.</exception>
public static ReadOnlyMemory<byte> DecodeFromBase64Utf8(ReadOnlyMemory<byte> base64Data)
{
int maxLength = Base64.GetMaxDecodedFromUtf8Length(base64Data.Length);
byte[] buffer = new byte[maxLength];
if (Base64.DecodeFromUtf8(base64Data.Span, buffer, out _, out int bytesWritten) == OperationStatus.Done)
{
// Base64 decoding may produce fewer bytes than the max length, due to whitespace anywhere in the string or padding.
Debug.Assert(bytesWritten <= buffer.Length, "Base64 decoding should never produce more bytes than the max length");
return buffer.AsMemory(0, bytesWritten);
}
else
{
throw new FormatException("Invalid base64 data");
}
}
}
50 changes: 50 additions & 0 deletions src/Common/Polyfills/System/Text/EncodingExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#if !NET

namespace System.Text;

internal static class EncodingExtensions
{
/// <summary>
/// Gets the number of bytes required to encode the specified characters.
/// </summary>
public static int GetByteCount(this Encoding encoding, ReadOnlySpan<char> chars)
{
if (chars.IsEmpty)
{
return 0;
}

unsafe
{
fixed (char* charsPtr = chars)
{
return encoding.GetByteCount(charsPtr, chars.Length);
}
}
}

/// <summary>
/// Encodes the specified characters into the specified byte span.
/// </summary>
public static int GetBytes(this Encoding encoding, ReadOnlySpan<char> chars, Span<byte> bytes)
{
if (chars.IsEmpty)
{
return 0;
}

unsafe
{
fixed (char* charsPtr = chars)
fixed (byte* bytesPtr = bytes)
{
return encoding.GetBytes(charsPtr, chars.Length, bytesPtr, bytes.Length);
}
}
}
}

#endif
8 changes: 0 additions & 8 deletions src/Common/ServerSentEvents/SseEventWriterHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,12 @@ public static void WriteUtf8String(this IBufferWriter<byte> writer, ReadOnlySpan
return;
}

#if NET
int maxByteCount = Encoding.UTF8.GetMaxByteCount(value.Length);
Span<byte> buffer = writer.GetSpan(maxByteCount);
Debug.Assert(buffer.Length >= maxByteCount);

int bytesWritten = Encoding.UTF8.GetBytes(value, buffer);
writer.Advance(bytesWritten);
#else
// netstandard2.0 doesn't have the Span overload of GetBytes
byte[] bytes = Encoding.UTF8.GetBytes(value.ToString());
Span<byte> buffer = writer.GetSpan(bytes.Length);
bytes.AsSpan().CopyTo(buffer);
writer.Advance(bytes.Length);
#endif
}

public static bool ContainsLineBreaks(this ReadOnlySpan<char> text) =>
Expand Down
16 changes: 7 additions & 9 deletions src/ModelContextProtocol.Core/AIContentExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
#if !NET
using System.Runtime.InteropServices;
#endif
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;

Expand Down Expand Up @@ -281,9 +279,9 @@ public static IList<PromptMessage> 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(),

Expand Down Expand Up @@ -324,7 +322,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.DecodedData, blobResource.MimeType ?? "application/octet-stream"),
TextResourceContents textResource => new TextContent(textResource.Text),
_ => throw new NotSupportedException($"Resource type '{content.GetType().Name}' is not supported.")
};
Expand Down Expand Up @@ -401,21 +399,21 @@ public static ContentBlock ToContentBlock(this AIContent content, JsonSerializer

DataContent dataContent when dataContent.HasTopLevelMediaType("image") => new ImageContentBlock
{
Data = dataContent.Base64Data.ToString(),
Data = EncodingUtilities.GetUtf8Bytes(dataContent.Base64Data.Span),
MimeType = dataContent.MediaType,
},

DataContent dataContent when dataContent.HasTopLevelMediaType("audio") => new AudioContentBlock
{
Data = dataContent.Base64Data.ToString(),
Data = EncodingUtilities.GetUtf8Bytes(dataContent.Base64Data.Span),
MimeType = dataContent.MediaType,
},

DataContent dataContent => new EmbeddedResourceBlock
{
Resource = new BlobResourceContents
{
Blob = dataContent.Base64Data.ToString(),
Blob = EncodingUtilities.GetUtf8Bytes(dataContent.Base64Data.Span),
MimeType = dataContent.MediaType,
Uri = string.Empty,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<Compile Include="..\Common\Throw.cs" Link="Throw.cs" />
<Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" />
<Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" />
<Compile Include="..\Common\EncodingUtilities.cs" Link="EncodingUtilities.cs" />
<Compile Include="..\Common\HttpResponseMessageExtensions.cs" Link="HttpResponseMessageExtensions.cs" />
<Compile Include="..\Common\ServerSentEvents\**\*.cs" Link="ServerSentEvents\%(RecursiveDir)%(FileName)%(Extension)" />
</ItemGroup>
Expand Down
69 changes: 65 additions & 4 deletions src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using System.Buffers;
using System.Buffers.Text;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text.Json.Serialization;

namespace ModelContextProtocol.Protocol;
Expand All @@ -9,7 +12,7 @@ namespace ModelContextProtocol.Protocol;
/// <remarks>
/// <para>
/// <see cref="BlobResourceContents"/> is used when binary data needs to be exchanged through
/// the Model Context Protocol. The binary data is represented as a base64-encoded string
/// the Model Context Protocol. The binary data is represented as base64-encoded UTF-8 bytes
/// in the <see cref="Blob"/> property.
/// </para>
/// <para>
Expand All @@ -24,18 +27,76 @@ namespace ModelContextProtocol.Protocol;
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class BlobResourceContents : ResourceContents
{
private ReadOnlyMemory<byte>? _decodedData;
private ReadOnlyMemory<byte> _blob;

/// <summary>
/// Creates an <see cref="BlobResourceContents"/> from raw data.
/// </summary>
/// <param name="bytes">The raw unencoded data.</param>
/// <param name="uri">The URI of the blob resource.</param>
/// <param name="mimeType">The optional MIME type of the data.</param>
/// <returns>A new <see cref="BlobResourceContents"/> instance.</returns>
/// <exception cref="InvalidOperationException"></exception>
public static BlobResourceContents FromBytes(ReadOnlyMemory<byte> bytes, string uri, string? mimeType = null)
{
ReadOnlyMemory<byte> blob = EncodingUtilities.EncodeToBase64Utf8(bytes);

return new()
{
_decodedData = bytes,
Blob = blob,
MimeType = mimeType,
Uri = uri
};
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Setting this value will invalidate any cached value of <see cref="DecodedData"/>.
/// </remarks>
[JsonPropertyName("blob")]
public required string Blob { get; set; }
public required ReadOnlyMemory<byte> Blob
{
get => _blob;
set
{
_blob = value;
_decodedData = null; // Invalidate cache
}
}

/// <summary>
/// Gets the decoded data represented by <see cref="Blob"/>.
/// </summary>
/// <remarks>
/// <para>
/// When getting, this member will decode the value in <see cref="Blob"/> and cache the result.
/// Subsequent accesses return the cached value unless <see cref="Blob"/> is modified.
/// </para>
/// </remarks>
[JsonIgnore]
public ReadOnlyMemory<byte> DecodedData
{
get
{
if (_decodedData is null)
{
_decodedData = EncodingUtilities.DecodeFromBase64Utf8(Blob);
}

return _decodedData.Value;
}
}

[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay
{
get
{
string lengthDisplay = DebuggerDisplayHelper.GetBase64LengthDisplay(Blob);
string lengthDisplay = _decodedData is null ? DebuggerDisplayHelper.GetBase64LengthDisplay(Blob) : $"{DecodedData.Length} bytes";
string mimeInfo = MimeType is not null ? $", MimeType = {MimeType}" : "";
return $"Uri = \"{Uri}\"{mimeInfo}, Length = {lengthDisplay}";
}
Expand Down
Loading