Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
2 changes: 1 addition & 1 deletion samples/EverythingServer/Resources/SimpleResourceType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public static ResourceContents TemplateResource(RequestContext<ReadResourceReque
} :
new BlobResourceContents
{
Blob = resource.Description!,
Blob = System.Text.Encoding.UTF8.GetBytes(resource.Description!),
MimeType = resource.MimeType,
Uri = resource.Uri,
};
Expand Down
2 changes: 1 addition & 1 deletion samples/EverythingServer/Tools/AnnotatedMessageTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public static IEnumerable<ContentBlock> 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()),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: consider changing this to a span literal to avoid the need to parse a static resource. Both DataContent and ImageContentBlock can construct from the raw bytes.

MimeType = "image/png",
Annotations = new() { Audience = [Role.User], Priority = 0.5f }
});
Expand Down
12 changes: 6 additions & 6 deletions src/ModelContextProtocol.Core/AIContentExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -264,9 +264,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 @@ -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.")
};
Expand Down Expand Up @@ -380,21 +380,21 @@ 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()),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DataContent already has Data which is decoded. Can we use that?

MimeType = dataContent.MediaType,
},

DataContent dataContent when dataContent.HasTopLevelMediaType("audio") => new AudioContentBlock
{
Data = dataContent.Base64Data.ToString(),
Data = System.Text.Encoding.UTF8.GetBytes(dataContent.Base64Data.ToString()),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, can we use Data

MimeType = dataContent.MediaType,
},

DataContent dataContent => new EmbeddedResourceBlock
{
Resource = new BlobResourceContents
{
Blob = dataContent.Base64Data.ToString(),
Blob = System.Text.Encoding.UTF8.GetBytes(dataContent.Base64Data.ToString()),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should just be Base64Data and not go to string and back to bytes.

MimeType = dataContent.MediaType,
Uri = string.Empty,
}
Expand Down
48 changes: 44 additions & 4 deletions src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Runtime.InteropServices;
using System.Text.Json.Serialization;

namespace ModelContextProtocol.Protocol;
Expand All @@ -8,8 +9,8 @@ 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
/// in the <see cref="Blob"/> property.
/// the Model Context Protocol. The binary data is represented as base64-encoded UTF-8 bytes
/// in the <see cref="Blob"/> property, providing a zero-copy representation of the wire payload.
/// </para>
/// <para>
/// This class inherits from <see cref="ResourceContents"/>, which also has a sibling implementation
Expand All @@ -22,9 +23,48 @@ namespace ModelContextProtocol.Protocol;
/// </remarks>
public sealed class BlobResourceContents : ResourceContents
{
private byte[]? _decodedData;
private ReadOnlyMemory<byte> _blob;

/// <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>
/// This is a zero-copy representation of the wire payload of this item. Setting this value will invalidate any cached value of <see cref="Data"/>.
/// </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>
/// Accessing 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.
/// </remarks>
[JsonIgnore]
public ReadOnlyMemory<byte> Data
{
get
{
if (_decodedData is null)
{
#if NET
_decodedData = Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(Blob.Span));
#else
byte[] array = MemoryMarshal.TryGetArray(Blob, out ArraySegment<byte> segment) && segment.Offset == 0 && segment.Count == segment.Array!.Length ? segment.Array : Blob.ToArray();
_decodedData = Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(array));
#endif
}
return _decodedData;
}
}
}
106 changes: 98 additions & 8 deletions src/ModelContextProtocol.Core/Protocol/ContentBlock.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.Buffers;
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;
Expand Down Expand Up @@ -87,7 +89,7 @@ public class Converter : JsonConverter<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;
Expand Down Expand Up @@ -128,7 +130,15 @@ public class Converter : JsonConverter<ContentBlock>
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":
Expand Down Expand Up @@ -279,12 +289,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;

Expand Down Expand Up @@ -371,14 +383,53 @@ public sealed class TextContentBlock : ContentBlock
/// <summary>Represents an image provided to or from an LLM.</summary>
public sealed class ImageContentBlock : ContentBlock
{
private byte[]? _decodedData;
private ReadOnlyMemory<byte> _data;

/// <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>
/// This is a zero-copy representation of the wire payload of this item. 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>
/// Accessing 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.
/// </remarks>
[JsonIgnore]
public ReadOnlyMemory<byte> DecodedData
{
get
{
if (_decodedData is null)
{
#if NET
_decodedData = Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(Data.Span));
#else
byte[] array = MemoryMarshal.TryGetArray(Data, out ArraySegment<byte> segment) && segment.Offset == 0 && segment.Count == segment.Array!.Length ? segment.Array : Data.ToArray();
_decodedData = Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(array));
#endif
}
return _decodedData;
}
}

/// <summary>
/// Gets or sets the MIME type (or "media type") of the content, specifying the format of the data.
Expand All @@ -393,14 +444,53 @@ public sealed class ImageContentBlock : ContentBlock
/// <summary>Represents audio provided to or from an LLM.</summary>
public sealed class AudioContentBlock : ContentBlock
{
private byte[]? _decodedData;
private ReadOnlyMemory<byte> _data;

/// <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>
/// This is a zero-copy representation of the wire payload of this item. 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>
/// Accessing 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.
/// </remarks>
[JsonIgnore]
public ReadOnlyMemory<byte> DecodedData
{
get
{
if (_decodedData is null)
{
#if NET
_decodedData = Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(Data.Span));
#else
byte[] array = MemoryMarshal.TryGetArray(Data, out ArraySegment<byte> segment) && segment.Offset == 0 && segment.Count == segment.Array!.Length ? segment.Array : Data.ToArray();
_decodedData = Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(array));
#endif
}
return _decodedData;
}
}

/// <summary>
/// Gets or sets the MIME type (or "media type") of the content, specifying the format of the data.
Expand Down
18 changes: 14 additions & 4 deletions src/ModelContextProtocol.Core/Protocol/ResourceContents.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Buffers;
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
Expand Down Expand Up @@ -78,7 +79,7 @@ public class Converter : JsonConverter<ResourceContents>

string? uri = null;
string? mimeType = null;
string? blob = null;
ReadOnlyMemory<byte>? blob = null;
string? text = null;
JsonObject? meta = null;

Expand All @@ -104,7 +105,15 @@ public class Converter : JsonConverter<ResourceContents>
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":
Expand All @@ -127,7 +136,7 @@ public class Converter : JsonConverter<ResourceContents>
{
Uri = uri ?? string.Empty,
MimeType = mimeType,
Blob = blob,
Blob = blob.Value,
Meta = meta,
};
}
Expand Down Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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;
Expand Down Expand Up @@ -391,7 +392,14 @@ public override async ValueTask<ReadResourceResult> 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 = MemoryMarshal.TryGetArray(dc.Base64Data, out ArraySegment<char> segment) && segment.Offset == 0 && segment.Count == segment.Array!.Length
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot, these two don't need to Offset and Count, as the actual offset and length can be passed as arguments to GetBytes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to use the GetBytes(char[], int, int) overload to pass offset and count directly in commit 1d76c11

? System.Text.Encoding.UTF8.GetBytes(segment.Array)
: System.Text.Encoding.UTF8.GetBytes(dc.Base64Data.ToString())
}],
},

string text => new()
Expand Down Expand Up @@ -420,7 +428,9 @@ public override async ValueTask<ReadResourceResult> ReadAsync(
{
Uri = request.Params!.Uri,
MimeType = dc.MediaType,
Blob = dc.Base64Data.ToString()
Blob = MemoryMarshal.TryGetArray(dc.Base64Data, out ArraySegment<char> segment) && segment.Offset == 0 && segment.Count == segment.Array!.Length
? System.Text.Encoding.UTF8.GetBytes(segment.Array)
: System.Text.Encoding.UTF8.GetBytes(dc.Base64Data.ToString())
},

_ => throw new InvalidOperationException($"Unsupported AIContent type '{ac.GetType()}' returned from resource function."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ public async Task ReadResource_Sse_BinaryResource()
Assert.Single(result.Contents);

BlobResourceContents blobContent = Assert.IsType<BlobResourceContents>(result.Contents[0]);
Assert.NotNull(blobContent.Blob);
Assert.False(blobContent.Blob.IsEmpty);
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public static IEnumerable<PromptMessage> PromptWithImage()
Content = new ImageContentBlock
{
MimeType = "image/png",
Data = TestImageBase64
Data = System.Text.Encoding.UTF8.GetBytes(TestImageBase64)
}
},
new PromptMessage
Expand Down
Loading