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:
parent
a3ce9e9606
commit
80b1ad21d7
32 changed files with 400 additions and 103 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace EverythingServer.Resources;
|
namespace EverythingServer.Resources;
|
||||||
|
|
||||||
|
|
@ -31,7 +32,7 @@ public class SimpleResourceType
|
||||||
} :
|
} :
|
||||||
new BlobResourceContents
|
new BlobResourceContents
|
||||||
{
|
{
|
||||||
Blob = resource.Description!,
|
Blob = Encoding.UTF8.GetBytes(resource.Description!),
|
||||||
MimeType = resource.MimeType,
|
MimeType = resource.MimeType,
|
||||||
Uri = resource.Uri,
|
Uri = resource.Uri,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace EverythingServer.Tools;
|
namespace EverythingServer.Tools;
|
||||||
|
|
||||||
|
|
@ -41,7 +42,7 @@ public class AnnotatedMessageTool
|
||||||
{
|
{
|
||||||
contents.Add(new ImageContentBlock
|
contents.Add(new ImageContentBlock
|
||||||
{
|
{
|
||||||
Data = TinyImageTool.MCP_TINY_IMAGE.Split(",").Last(),
|
Data = Encoding.UTF8.GetBytes(TinyImageTool.MCP_TINY_IMAGE.Split(",").Last()),
|
||||||
MimeType = "image/png",
|
MimeType = "image/png",
|
||||||
Annotations = new() { Audience = [Role.User], Priority = 0.5f }
|
Annotations = new() { Audience = [Role.User], Priority = 0.5f }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
59
src/Common/EncodingUtilities.cs
Normal file
59
src/Common/EncodingUtilities.cs
Normal file
|
|
@ -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
src/Common/Polyfills/System/Text/EncodingExtensions.cs
Normal file
50
src/Common/Polyfills/System/Text/EncodingExtensions.cs
Normal file
|
|
@ -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
|
||||||
|
|
@ -47,20 +47,12 @@ internal static class SseEventWriterHelpers
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
#if NET
|
|
||||||
int maxByteCount = Encoding.UTF8.GetMaxByteCount(value.Length);
|
int maxByteCount = Encoding.UTF8.GetMaxByteCount(value.Length);
|
||||||
Span<byte> buffer = writer.GetSpan(maxByteCount);
|
Span<byte> buffer = writer.GetSpan(maxByteCount);
|
||||||
Debug.Assert(buffer.Length >= maxByteCount);
|
Debug.Assert(buffer.Length >= maxByteCount);
|
||||||
|
|
||||||
int bytesWritten = Encoding.UTF8.GetBytes(value, buffer);
|
int bytesWritten = Encoding.UTF8.GetBytes(value, buffer);
|
||||||
writer.Advance(bytesWritten);
|
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) =>
|
public static bool ContainsLineBreaks(this ReadOnlySpan<char> text) =>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using ModelContextProtocol.Client;
|
using ModelContextProtocol.Client;
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
#if !NET
|
using System.Text;
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
#endif
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
|
@ -281,9 +279,9 @@ public static class AIContentExtensions
|
||||||
{
|
{
|
||||||
TextContentBlock textContent => new TextContent(textContent.Text),
|
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(),
|
EmbeddedResourceBlock resourceContent => resourceContent.Resource.ToAIContent(),
|
||||||
|
|
||||||
|
|
@ -324,7 +322,7 @@ public static class AIContentExtensions
|
||||||
|
|
||||||
AIContent ac = content switch
|
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),
|
TextResourceContents textResource => new TextContent(textResource.Text),
|
||||||
_ => throw new NotSupportedException($"Resource type '{content.GetType().Name}' is not supported.")
|
_ => 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
|
DataContent dataContent when dataContent.HasTopLevelMediaType("image") => new ImageContentBlock
|
||||||
{
|
{
|
||||||
Data = dataContent.Base64Data.ToString(),
|
Data = EncodingUtilities.GetUtf8Bytes(dataContent.Base64Data.Span),
|
||||||
MimeType = dataContent.MediaType,
|
MimeType = dataContent.MediaType,
|
||||||
},
|
},
|
||||||
|
|
||||||
DataContent dataContent when dataContent.HasTopLevelMediaType("audio") => new AudioContentBlock
|
DataContent dataContent when dataContent.HasTopLevelMediaType("audio") => new AudioContentBlock
|
||||||
{
|
{
|
||||||
Data = dataContent.Base64Data.ToString(),
|
Data = EncodingUtilities.GetUtf8Bytes(dataContent.Base64Data.Span),
|
||||||
MimeType = dataContent.MediaType,
|
MimeType = dataContent.MediaType,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -415,7 +413,7 @@ public static class AIContentExtensions
|
||||||
{
|
{
|
||||||
Resource = new BlobResourceContents
|
Resource = new BlobResourceContents
|
||||||
{
|
{
|
||||||
Blob = dataContent.Base64Data.ToString(),
|
Blob = EncodingUtilities.GetUtf8Bytes(dataContent.Base64Data.Span),
|
||||||
MimeType = dataContent.MediaType,
|
MimeType = dataContent.MediaType,
|
||||||
Uri = string.Empty,
|
Uri = string.Empty,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
<Compile Include="..\Common\Throw.cs" Link="Throw.cs" />
|
<Compile Include="..\Common\Throw.cs" Link="Throw.cs" />
|
||||||
<Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" />
|
<Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" />
|
||||||
<Compile Include="..\Common\Experimentals.cs" Link="Experimentals.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\HttpResponseMessageExtensions.cs" Link="HttpResponseMessageExtensions.cs" />
|
||||||
<Compile Include="..\Common\ServerSentEvents\**\*.cs" Link="ServerSentEvents\%(RecursiveDir)%(FileName)%(Extension)" />
|
<Compile Include="..\Common\ServerSentEvents\**\*.cs" Link="ServerSentEvents\%(RecursiveDir)%(FileName)%(Extension)" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
|
using System.Buffers;
|
||||||
|
using System.Buffers.Text;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Protocol;
|
namespace ModelContextProtocol.Protocol;
|
||||||
|
|
@ -9,7 +12,7 @@ namespace ModelContextProtocol.Protocol;
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <see cref="BlobResourceContents"/> is used when binary data needs to be exchanged through
|
/// <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.
|
/// in the <see cref="Blob"/> property.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -24,18 +27,76 @@ namespace ModelContextProtocol.Protocol;
|
||||||
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||||
public sealed class BlobResourceContents : ResourceContents
|
public sealed class BlobResourceContents : ResourceContents
|
||||||
{
|
{
|
||||||
|
private ReadOnlyMemory<byte>? _decodedData;
|
||||||
|
private ReadOnlyMemory<byte> _blob;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the base64-encoded string representing the binary data of the item.
|
/// Creates an <see cref="BlobResourceContents"/> from raw data.
|
||||||
/// </summary>
|
/// </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")]
|
[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)]
|
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
|
||||||
private string DebuggerDisplay
|
private string DebuggerDisplay
|
||||||
{
|
{
|
||||||
get
|
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}" : "";
|
string mimeInfo = MimeType is not null ? $", MimeType = {MimeType}" : "";
|
||||||
return $"Uri = \"{Uri}\"{mimeInfo}, Length = {lengthDisplay}";
|
return $"Uri = \"{Uri}\"{mimeInfo}, Length = {lengthDisplay}";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
using System.Buffers;
|
||||||
|
using System.Buffers.Text;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
@ -87,7 +90,7 @@ public abstract class ContentBlock
|
||||||
string? type = null;
|
string? type = null;
|
||||||
string? text = null;
|
string? text = null;
|
||||||
string? name = null;
|
string? name = null;
|
||||||
string? data = null;
|
ReadOnlyMemory<byte>? data = null;
|
||||||
string? mimeType = null;
|
string? mimeType = null;
|
||||||
string? uri = null;
|
string? uri = null;
|
||||||
string? description = null;
|
string? description = null;
|
||||||
|
|
@ -128,7 +131,7 @@ public abstract class ContentBlock
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "data":
|
case "data":
|
||||||
data = reader.GetString();
|
data = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "mimeType":
|
case "mimeType":
|
||||||
|
|
@ -279,12 +282,12 @@ public abstract class ContentBlock
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ImageContentBlock imageContent:
|
case ImageContentBlock imageContent:
|
||||||
writer.WriteString("data", imageContent.Data);
|
writer.WriteString("data", imageContent.Data.Span);
|
||||||
writer.WriteString("mimeType", imageContent.MimeType);
|
writer.WriteString("mimeType", imageContent.MimeType);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case AudioContentBlock audioContent:
|
case AudioContentBlock audioContent:
|
||||||
writer.WriteString("data", audioContent.Data);
|
writer.WriteString("data", audioContent.Data.Span);
|
||||||
writer.WriteString("mimeType", audioContent.MimeType);
|
writer.WriteString("mimeType", audioContent.MimeType);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -376,14 +379,73 @@ public sealed class TextContentBlock : ContentBlock
|
||||||
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||||
public sealed class ImageContentBlock : ContentBlock
|
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/>
|
/// <inheritdoc/>
|
||||||
public override string Type => "image";
|
public override string Type => "image";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the base64-encoded image data.
|
/// Gets or sets the base64-encoded UTF-8 bytes representing the image data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Setting this value will invalidate any cached value of <see cref="DecodedData"/>.
|
||||||
|
/// </remarks>
|
||||||
[JsonPropertyName("data")]
|
[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>
|
/// <summary>
|
||||||
/// Gets or sets the MIME type (or "media type") of the content, specifying the format of the data.
|
/// 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}")]
|
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||||
public sealed class AudioContentBlock : ContentBlock
|
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/>
|
/// <inheritdoc/>
|
||||||
public override string Type => "audio";
|
public override string Type => "audio";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the base64-encoded audio data.
|
/// Gets or sets the base64-encoded UTF-8 bytes representing the audio data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Setting this value will invalidate any cached value of <see cref="DecodedData"/>.
|
||||||
|
/// </remarks>
|
||||||
[JsonPropertyName("data")]
|
[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>
|
/// <summary>
|
||||||
/// Gets or sets the MIME type (or "media type") of the content, specifying the format of the data.
|
/// Gets or sets the MIME type (or "media type") of the content, specifying the format of the data.
|
||||||
|
|
|
||||||
|
|
@ -27,4 +27,25 @@ internal static class DebuggerDisplayHelper
|
||||||
|
|
||||||
return "invalid base64";
|
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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Buffers;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
@ -78,7 +79,7 @@ public abstract class ResourceContents
|
||||||
|
|
||||||
string? uri = null;
|
string? uri = null;
|
||||||
string? mimeType = null;
|
string? mimeType = null;
|
||||||
string? blob = null;
|
ReadOnlyMemory<byte>? blob = null;
|
||||||
string? text = null;
|
string? text = null;
|
||||||
JsonObject? meta = null;
|
JsonObject? meta = null;
|
||||||
|
|
||||||
|
|
@ -104,7 +105,7 @@ public abstract class ResourceContents
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "blob":
|
case "blob":
|
||||||
blob = reader.GetString();
|
blob = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "text":
|
case "text":
|
||||||
|
|
@ -127,7 +128,7 @@ public abstract class ResourceContents
|
||||||
{
|
{
|
||||||
Uri = uri ?? string.Empty,
|
Uri = uri ?? string.Empty,
|
||||||
MimeType = mimeType,
|
MimeType = mimeType,
|
||||||
Blob = blob,
|
Blob = blob.Value,
|
||||||
Meta = meta,
|
Meta = meta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +163,7 @@ public abstract class ResourceContents
|
||||||
Debug.Assert(value is BlobResourceContents or TextResourceContents);
|
Debug.Assert(value is BlobResourceContents or TextResourceContents);
|
||||||
if (value is BlobResourceContents blobResource)
|
if (value is BlobResourceContents blobResource)
|
||||||
{
|
{
|
||||||
writer.WriteString("blob", blobResource.Blob);
|
writer.WriteString("blob", blobResource.Blob.Span);
|
||||||
}
|
}
|
||||||
else if (value is TextResourceContents textResource)
|
else if (value is TextResourceContents textResource)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
@ -391,7 +392,12 @@ internal sealed class AIFunctionMcpServerResource : McpServerResource
|
||||||
|
|
||||||
DataContent dc => new()
|
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()
|
string text => new()
|
||||||
|
|
@ -420,7 +426,7 @@ internal sealed class AIFunctionMcpServerResource : McpServerResource
|
||||||
{
|
{
|
||||||
Uri = request.Params!.Uri,
|
Uri = request.Params!.Uri,
|
||||||
MimeType = dc.MediaType,
|
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."),
|
_ => throw new InvalidOperationException($"Unsupported AIContent type '{ac.GetType()}' returned from resource function."),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<PackageId>ModelContextProtocol</PackageId>
|
<PackageId>ModelContextProtocol</PackageId>
|
||||||
<Description>.NET SDK for the Model Context Protocol (MCP) with hosting and dependency injection extensions.</Description>
|
<Description>.NET SDK for the Model Context Protocol (MCP) with hosting and dependency injection extensions.</Description>
|
||||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
||||||
<!-- Suppress the experimental tasks warning -->
|
<!-- Suppress the experimental tasks warning -->
|
||||||
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
|
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)..'))" />
|
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)..'))" />
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
||||||
<!-- Suppress the experimental tasks warning in test projects -->
|
<!-- Suppress the experimental tasks warning in test projects -->
|
||||||
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
|
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<Sse
|
||||||
Assert.Single(result.Contents);
|
Assert.Single(result.Contents);
|
||||||
|
|
||||||
BlobResourceContents blobContent = Assert.IsType<BlobResourceContents>(result.Contents[0]);
|
BlobResourceContents blobContent = Assert.IsType<BlobResourceContents>(result.Contents[0]);
|
||||||
Assert.NotNull(blobContent.Blob);
|
Assert.False(blobContent.Blob.IsEmpty);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ public class ConformancePrompts
|
||||||
Content = new ImageContentBlock
|
Content = new ImageContentBlock
|
||||||
{
|
{
|
||||||
MimeType = "image/png",
|
MimeType = "image/png",
|
||||||
Data = TestImageBase64
|
Data = System.Text.Encoding.UTF8.GetBytes(TestImageBase64)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
new PromptMessage
|
new PromptMessage
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ public class ConformanceResources
|
||||||
{
|
{
|
||||||
Uri = "test://static-binary",
|
Uri = "test://static-binary",
|
||||||
MimeType = "image/png",
|
MimeType = "image/png",
|
||||||
Blob = TestImageBase64
|
Blob = System.Text.Encoding.UTF8.GetBytes(TestImageBase64)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ public class ConformanceTools
|
||||||
{
|
{
|
||||||
return new ImageContentBlock
|
return new ImageContentBlock
|
||||||
{
|
{
|
||||||
Data = TestImageBase64,
|
Data = System.Text.Encoding.UTF8.GetBytes(TestImageBase64),
|
||||||
MimeType = "image/png"
|
MimeType = "image/png"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +50,7 @@ public class ConformanceTools
|
||||||
{
|
{
|
||||||
return new AudioContentBlock
|
return new AudioContentBlock
|
||||||
{
|
{
|
||||||
Data = TestAudioBase64,
|
Data = System.Text.Encoding.UTF8.GetBytes(TestAudioBase64),
|
||||||
MimeType = "audio/wav"
|
MimeType = "audio/wav"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +83,7 @@ public class ConformanceTools
|
||||||
return
|
return
|
||||||
[
|
[
|
||||||
new TextContentBlock { Text = "Multiple content types test:" },
|
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
|
new EmbeddedResourceBlock
|
||||||
{
|
{
|
||||||
Resource = new TextResourceContents
|
Resource = new TextResourceContents
|
||||||
|
|
|
||||||
|
|
@ -401,12 +401,7 @@ internal static class Program
|
||||||
Name = $"Resource {i + 1}",
|
Name = $"Resource {i + 1}",
|
||||||
MimeType = "application/octet-stream"
|
MimeType = "application/octet-stream"
|
||||||
});
|
});
|
||||||
resourceContents.Add(new BlobResourceContents
|
resourceContents.Add(BlobResourceContents.FromBytes(buffer, uri, "application/octet-stream"));
|
||||||
{
|
|
||||||
Uri = uri,
|
|
||||||
MimeType = "application/octet-stream",
|
|
||||||
Blob = Convert.ToBase64String(buffer)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,12 +85,7 @@ public class Program
|
||||||
Name = $"Resource {i + 1}",
|
Name = $"Resource {i + 1}",
|
||||||
MimeType = "application/octet-stream"
|
MimeType = "application/octet-stream"
|
||||||
});
|
});
|
||||||
resourceContents.Add(new BlobResourceContents
|
resourceContents.Add(BlobResourceContents.FromBytes(buffer, uri, "application/octet-stream"));
|
||||||
{
|
|
||||||
Uri = uri,
|
|
||||||
MimeType = "application/octet-stream",
|
|
||||||
Blob = Convert.ToBase64String(buffer)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,7 +376,7 @@ public class Program
|
||||||
Role = Role.User,
|
Role = Role.User,
|
||||||
Content = new ImageContentBlock
|
Content = new ImageContentBlock
|
||||||
{
|
{
|
||||||
Data = MCP_TINY_IMAGE,
|
Data = System.Text.Encoding.UTF8.GetBytes(MCP_TINY_IMAGE),
|
||||||
MimeType = "image/png"
|
MimeType = "image/png"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue