Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
46 changes: 42 additions & 4 deletions src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,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 +22,47 @@ 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 NET6_0_OR_GREATER
Copy link
Contributor

Choose a reason for hiding this comment

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

@copilot, this can just be #if NET.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed to #if NET in commit ebe3eef

_decodedData = Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(Blob.Span));
#else
_decodedData = Convert.FromBase64String(System.Text.Encoding.UTF8.GetString(Blob.ToArray()));
Copy link
Contributor

Choose a reason for hiding this comment

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

@copilot, this can extract the array using MemoryMarshal.TryGetArray and use it in a majority case, e.g.

byte[] array = MemoryMarshal.TryGetArray(Blob, out ArraySegment<byte> segment) && segment.Offset == 0 && segment.Count == segment.Array.Length ? segment.Array : Blob.ToArray();
_decodedData = Convert.FromBase64String(array);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Applied the MemoryMarshal.TryGetArray optimization to avoid ToArray() in the common case in commit ebe3eef

#endif
}
return _decodedData;
}
}
}
103 changes: 95 additions & 8 deletions src/ModelContextProtocol.Core/Protocol/ContentBlock.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 @@ -87,7 +88,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 +129,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 +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;

Expand Down Expand Up @@ -371,14 +382,52 @@ 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 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;
}
}

/// <summary>
/// Gets or sets the MIME type (or "media type") of the content, specifying the format of the data.
Expand All @@ -393,14 +442,52 @@ 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 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;
}
}

/// <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 @@ -391,7 +391,7 @@ 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 = System.Text.Encoding.UTF8.GetBytes(dc.Base64Data.ToString()) }],
},

string text => new()
Expand Down Expand Up @@ -420,7 +420,7 @@ public override async ValueTask<ReadResourceResult> ReadAsync(
{
Uri = request.Params!.Uri,
MimeType = dc.MediaType,
Blob = dc.Base64Data.ToString()
Blob = System.Text.Encoding.UTF8.GetBytes(dc.Base64Data.ToString())
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 cases where it's going from a ReadOnlyMemory<char> through UTF8.GetBytes can similarly use TryGetArray to avoid a ToString() in the common case.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Applied MemoryMarshal.TryGetArray optimization for ReadOnlyMemory to avoid ToString() in the common case in commit ebe3eef

},

_ => 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
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public static BlobResourceContents StaticBinary()
{
Uri = "test://static-binary",
MimeType = "image/png",
Blob = TestImageBase64
Blob = System.Text.Encoding.UTF8.GetBytes(TestImageBase64)
};
}

Expand Down
Loading