Use ReadOnlyMemory<byte> for binary data to eliminate UTF-16 transcoding (#1070)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: ericstj <8918108+ericstj@users.noreply.github.com>
Co-authored-by: Eric StJohn <ericstj@microsoft.com>
This commit is contained in:
Copilot 2026-02-19 14:08:05 -08:00 • committed by GitHub
commit 80b1ad21d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 400 additions and 103 deletions

View file

@ -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;
@ -281,9 +279,9 @@ public static class AIContentExtensions
{
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(),
@ -324,7 +322,7 @@ public static class AIContentExtensions
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.")
};
@ -401,13 +399,13 @@ public static class AIContentExtensions
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,
},
@ -415,7 +413,7 @@ public static class AIContentExtensions
{
Resource = new BlobResourceContents
{
Blob = dataContent.Base64Data.ToString(),
Blob = EncodingUtilities.GetUtf8Bytes(dataContent.Base64Data.Span),
MimeType = dataContent.MediaType,
Uri = string.Empty,
}

View file

@ -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>

View file

@ -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;
@ -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>
@ -24,18 +27,76 @@ namespace ModelContextProtocol.Protocol;
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class BlobResourceContents : ResourceContents
{
private ReadOnlyMemory<byte>? _decodedData;
private ReadOnlyMemory<byte> _blob;
/// <summary>
/// Gets or sets the base64-encoded string representing the binary data of the item.
/// 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 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}";
}

View file

@ -1,6 +1,9 @@
using System.Buffers;
using System.Buffers.Text;
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
@ -87,7 +90,7 @@ public abstract class ContentBlock
string? type = null;
string? text = null;
string? name = null;
string? data = null;
ReadOnlyMemory<byte>? data = null;
string? mimeType = null;
string? uri = null;
string? description = null;
@ -128,7 +131,7 @@ public abstract class ContentBlock
break;
case "data":
data = reader.GetString();
data = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
break;
case "mimeType":
@ -279,12 +282,12 @@ public abstract class ContentBlock
break;
case ImageContentBlock imageContent:
writer.WriteString("data", imageContent.Data);
writer.WriteString("data", imageContent.Data.Span);
writer.WriteString("mimeType", imageContent.MimeType);
break;
case AudioContentBlock audioContent:
writer.WriteString("data", audioContent.Data);
writer.WriteString("data", audioContent.Data.Span);
writer.WriteString("mimeType", audioContent.MimeType);
break;
@ -376,14 +379,73 @@ public sealed class TextContentBlock : ContentBlock
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class ImageContentBlock : ContentBlock
{
private ReadOnlyMemory<byte>? _decodedData;
private ReadOnlyMemory<byte> _data;
/// <summary>
/// Creates an <see cref="ImageContentBlock"/> from decoded image bytes.
/// </summary>
/// <param name="bytes">The unencoded image bytes.</param>
/// <param name="mimeType">The MIME type of the image.</param>
/// <returns>A new <see cref="ImageContentBlock"/> instance.</returns>
/// <remarks>
/// This method stores the provided bytes as <see cref="DecodedData"/> and encodes them to base64 UTF-8 bytes for <see cref="Data"/>.
/// </remarks>
/// <exception cref="InvalidOperationException"></exception>
public static ImageContentBlock FromBytes(ReadOnlyMemory<byte> bytes, string mimeType)
{
ReadOnlyMemory<byte> data = EncodingUtilities.EncodeToBase64Utf8(bytes);
return new()
{
_decodedData = bytes,
Data = data,
MimeType = mimeType
};
}
/// <inheritdoc/>
public override string Type => "image";
/// <summary>
/// Gets or sets the base64-encoded image data.
/// Gets or sets the base64-encoded UTF-8 bytes representing the image data.
/// </summary>
/// <remarks>
/// Setting this value will invalidate any cached value of <see cref="DecodedData"/>.
/// </remarks>
[JsonPropertyName("data")]
public required string Data { get; set; }
public required ReadOnlyMemory<byte> Data
{
get => _data;
set
{
_data = value;
_decodedData = null; // Invalidate cache
}
}
/// <summary>
/// Gets the decoded image data represented by <see cref="Data"/>.
/// </summary>
/// <remarks>
/// <para>
/// When getting, this member will decode the value in <see cref="Data"/> and cache the result.
/// Subsequent accesses return the cached value unless <see cref="Data"/> is modified.
/// </para>
/// </remarks>
[JsonIgnore]
public ReadOnlyMemory<byte> DecodedData
{
get
{
if (_decodedData is null)
{
_decodedData = EncodingUtilities.DecodeFromBase64Utf8(Data);
}
return _decodedData.Value;
}
}
/// <summary>
/// Gets or sets the MIME type (or "media type") of the content, specifying the format of the data.
@ -402,14 +464,73 @@ public sealed class ImageContentBlock : ContentBlock
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class AudioContentBlock : ContentBlock
{
private ReadOnlyMemory<byte>? _decodedData;
private ReadOnlyMemory<byte> _data;
/// <summary>
/// Creates an <see cref="AudioContentBlock"/> from decoded audio bytes.
/// </summary>
/// <param name="bytes">The unencoded audio bytes.</param>
/// <param name="mimeType">The MIME type of the audio.</param>
/// <returns>A new <see cref="AudioContentBlock"/> instance.</returns>
/// <remarks>
/// This method stores the provided bytes as <see cref="DecodedData"/> and encodes them to base64 UTF-8 bytes for <see cref="Data"/>.
/// </remarks>
/// <exception cref="InvalidOperationException"></exception>
public static AudioContentBlock FromBytes(ReadOnlyMemory<byte> bytes, string mimeType)
{
ReadOnlyMemory<byte> data = EncodingUtilities.EncodeToBase64Utf8(bytes);
return new()
{
_decodedData = bytes,
Data = data,
MimeType = mimeType
};
}
/// <inheritdoc/>
public override string Type => "audio";
/// <summary>
/// Gets or sets the base64-encoded audio data.
/// Gets or sets the base64-encoded UTF-8 bytes representing the audio data.
/// </summary>
/// <remarks>
/// Setting this value will invalidate any cached value of <see cref="DecodedData"/>.
/// </remarks>
[JsonPropertyName("data")]
public required string Data { get; set; }
public required ReadOnlyMemory<byte> Data
{
get => _data;
set
{
_data = value;
_decodedData = null; // Invalidate cache
}
}
/// <summary>
/// Gets the decoded audio data represented by <see cref="Data"/>.
/// </summary>
/// <remarks>
/// <para>
/// When getting, this member will decode the value in <see cref="Data"/> and cache the result.
/// Subsequent accesses return the cached value unless <see cref="Data"/> is modified.
/// </para>
/// </remarks>
[JsonIgnore]
public ReadOnlyMemory<byte> DecodedData
{
get
{
if (_decodedData is null)
{
_decodedData = EncodingUtilities.DecodeFromBase64Utf8(Data);
}
return _decodedData.Value;
}
}
/// <summary>
/// Gets or sets the MIME type (or "media type") of the content, specifying the format of the data.

View file

@ -27,4 +27,25 @@ internal static class DebuggerDisplayHelper
return "invalid base64";
}
/// <summary>
/// Gets the decoded length of base64 data encoded as UTF-8 bytes for debugger display.
/// </summary>
internal static string GetBase64LengthDisplay(ReadOnlyMemory<byte> base64Data)
{
#if NET
if (System.Buffers.Text.Base64.IsValid(base64Data.Span, out int decodedLength))
{
return $"{decodedLength} bytes";
}
#else
try
{
return $"{Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(base64Data.ToArray())).Length} bytes";
}
catch { }
#endif
return "invalid base64";
}
}

View file

@ -1,3 +1,4 @@
using System.Buffers;
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
@ -78,7 +79,7 @@ public abstract class ResourceContents
string? uri = null;
string? mimeType = null;
string? blob = null;
ReadOnlyMemory<byte>? blob = null;
string? text = null;
JsonObject? meta = null;
@ -104,7 +105,7 @@ public abstract class ResourceContents
break;
case "blob":
blob = reader.GetString();
blob = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
break;
case "text":
@ -127,7 +128,7 @@ public abstract class ResourceContents
{
Uri = uri ?? string.Empty,
MimeType = mimeType,
Blob = blob,
Blob = blob.Value,
Meta = meta,
};
}
@ -162,7 +163,7 @@ public abstract class ResourceContents
Debug.Assert(value is BlobResourceContents or TextResourceContents);
if (value is BlobResourceContents blobResource)
{
writer.WriteString("blob", blobResource.Blob);
writer.WriteString("blob", blobResource.Blob.Span);
}
else if (value is TextResourceContents textResource)
{

View file

@ -7,6 +7,7 @@ using System.Diagnostics;
using System.Globalization;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
@ -391,7 +392,12 @@ internal sealed class AIFunctionMcpServerResource : McpServerResource
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 = EncodingUtilities.GetUtf8Bytes(dc.Base64Data.Span)
}],
},
string text => new()
@ -420,7 +426,7 @@ internal sealed class AIFunctionMcpServerResource : McpServerResource
{
Uri = request.Params!.Uri,
MimeType = dc.MediaType,
Blob = dc.Base64Data.ToString()
Blob = EncodingUtilities.GetUtf8Bytes(dc.Base64Data.Span)
},
_ => throw new InvalidOperationException($"Unsupported AIContent type '{ac.GetType()}' returned from resource function."),