Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
35 changes: 35 additions & 0 deletions src/Common/EncodingUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,39 @@ public static ReadOnlyMemory<byte> DecodeFromBase64Utf8(ReadOnlyMemory<byte> bas
throw new FormatException("Invalid base64 data");
}
}

/// <summary>
/// Validates that <paramref name="base64Utf8"/> is a well-formed base64 encoding (as UTF-8 bytes),
/// without retaining the decoded result.
/// </summary>
/// <remarks>
/// Several protocol types expose a <see cref="ReadOnlyMemory{Byte}"/> property that is documented to
/// hold the base64 text representation of binary data (as UTF-8 bytes), with a <c>FromBytes</c> factory
/// provided to construct an instance from the raw, unencoded bytes instead. Assigning raw bytes directly
/// to such a property is a common, easy mistake: the value serializes without error but produces a JSON
/// payload that is not valid base64, which only surfaces as a confusing <see cref="FormatException"/> on
/// the deserializing end. This validates eagerly, at the point of misuse, instead.
/// </remarks>
/// <exception cref="ArgumentException"><paramref name="base64Utf8"/> is not valid base64-encoded UTF-8 data.</exception>
public static void ValidateBase64Utf8(ReadOnlyMemory<byte> base64Utf8, string paramName, string typeName, string factoryMethodName)
{
int maxLength = Base64.GetMaxDecodedFromUtf8Length(base64Utf8.Length);
byte[] buffer = ArrayPool<byte>.Shared.Rent(maxLength);
try
{
OperationStatus status = Base64.DecodeFromUtf8(base64Utf8.Span, buffer, out int bytesConsumed, out _);
if (status != OperationStatus.Done || bytesConsumed != base64Utf8.Length)
{
throw new ArgumentException(
$"The value must be the base64 text representation of the binary data, as UTF-8 bytes, " +
$"not the raw, unencoded bytes. Use {typeName}.{factoryMethodName}(...) to construct an " +
$"instance from raw binary data instead.",
paramName);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
26 changes: 25 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,35 @@ private BlobResourceContents(ReadOnlyMemory<byte> decodedData, string uri, strin
MimeType = mimeType;
}

[SetsRequiredMembers]
private BlobResourceContents(ReadOnlyMemory<byte> base64Utf8, string uri, string? mimeType, bool alreadyBase64Encoded)
{
_blob = base64Utf8;
Uri = uri;
MimeType = mimeType;
}

/// <summary>
/// Creates a <see cref="BlobResourceContents"/> directly from base64-encoded UTF-8 bytes that are
/// already known to be valid, bypassing the validation the public <see cref="Blob"/> setter performs.
/// </summary>
/// <remarks>
/// Used by <see cref="ResourceContents.Converter"/> for the JSON fast path: when the wire <c>blob</c> value required no
/// unescaping, its raw UTF-8 bytes are exactly the base64 text as read from the document, so
/// re-validating them would be redundant.
/// </remarks>
internal static BlobResourceContents FromTrustedBase64Utf8(ReadOnlyMemory<byte> base64Utf8, string uri, string? mimeType) =>
new(base64Utf8, uri, mimeType, alreadyBase64Encoded: true);

/// <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"/>.
/// Setting this value will invalidate any cached value of <see cref="DecodedData"/>. The value must be
/// the base64 text representation of the data, as UTF-8 bytes — not the raw, unencoded bytes. To
/// construct an instance from raw binary data, use <see cref="FromBytes"/> instead.
/// </remarks>
/// <exception cref="ArgumentException">The assigned value is not valid base64-encoded UTF-8 data.</exception>
[JsonPropertyName("blob")]
public required ReadOnlyMemory<byte> Blob
{
Expand All @@ -77,6 +100,7 @@ public required ReadOnlyMemory<byte> Blob
}
set
{
EncodingUtilities.ValidateBase64Utf8(value, nameof(Blob), nameof(BlobResourceContents), nameof(FromBytes));
_blob = value;
_decodedData = null; // Invalidate cache
}
Expand Down
66 changes: 54 additions & 12 deletions src/ModelContextProtocol.Core/Protocol/ContentBlock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -243,20 +243,16 @@ public sealed class Converter : JsonConverter<ContentBlock>
"image" => decodedData is not null ?
ImageContentBlock.FromBytes(decodedData.Value,
mimeType ?? throw new JsonException("MIME type must be provided for 'image' type.")) :
new ImageContentBlock
{
Data = data ?? throw new JsonException("Image data must be provided for 'image' type."),
MimeType = mimeType ?? throw new JsonException("MIME type must be provided for 'image' type."),
},
ImageContentBlock.FromTrustedBase64Utf8(
data ?? throw new JsonException("Image data must be provided for 'image' type."),
mimeType ?? throw new JsonException("MIME type must be provided for 'image' type.")),

"audio" => decodedData is not null ?
AudioContentBlock.FromBytes(decodedData.Value,
mimeType ?? throw new JsonException("MIME type must be provided for 'audio' type.")) :
new AudioContentBlock
{
Data = data ?? throw new JsonException("Audio data must be provided for 'audio' type."),
MimeType = mimeType ?? throw new JsonException("MIME type must be provided for 'audio' type."),
},
AudioContentBlock.FromTrustedBase64Utf8(
data ?? throw new JsonException("Audio data must be provided for 'audio' type."),
mimeType ?? throw new JsonException("MIME type must be provided for 'audio' type.")),

"resource" => new EmbeddedResourceBlock
{
Expand Down Expand Up @@ -462,15 +458,37 @@ private ImageContentBlock(ReadOnlyMemory<byte> decodedData, string mimeType)
MimeType = mimeType;
}

[SetsRequiredMembers]
private ImageContentBlock(ReadOnlyMemory<byte> base64Utf8, string mimeType, bool alreadyBase64Encoded)
{
_data = base64Utf8;
MimeType = mimeType;
}

/// <summary>
/// Creates an <see cref="ImageContentBlock"/> directly from base64-encoded UTF-8 bytes that are
/// already known to be valid, bypassing the validation the public <see cref="Data"/> setter performs.
/// </summary>
/// <remarks>
/// Used by <see cref="ContentBlock.Converter"/> for the JSON fast path: when the wire <c>data</c> value
/// required no unescaping, its raw UTF-8 bytes are exactly the base64 text as read from the document,
/// so re-validating them would be redundant.
/// </remarks>
internal static ImageContentBlock FromTrustedBase64Utf8(ReadOnlyMemory<byte> base64Utf8, string mimeType) =>
new(base64Utf8, mimeType, alreadyBase64Encoded: true);

/// <inheritdoc/>
public override string Type => "image";

/// <summary>
/// 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"/>.
/// Setting this value will invalidate any cached value of <see cref="DecodedData"/>. The value must be
/// the base64 text representation of the data, as UTF-8 bytes — not the raw, unencoded bytes. To
/// construct an instance from raw binary data, use <see cref="FromBytes"/> instead.
/// </remarks>
/// <exception cref="ArgumentException">The assigned value is not valid base64-encoded UTF-8 data.</exception>
[JsonPropertyName("data")]
public required ReadOnlyMemory<byte> Data
{
Expand All @@ -486,6 +504,7 @@ public required ReadOnlyMemory<byte> Data
}
set
{
EncodingUtilities.ValidateBase64Utf8(value, nameof(Data), nameof(ImageContentBlock), nameof(FromBytes));
_data = value;
_decodedData = null; // Invalidate cache
}
Expand Down Expand Up @@ -571,15 +590,37 @@ private AudioContentBlock(ReadOnlyMemory<byte> decodedData, string mimeType)
MimeType = mimeType;
}

[SetsRequiredMembers]
private AudioContentBlock(ReadOnlyMemory<byte> base64Utf8, string mimeType, bool alreadyBase64Encoded)
{
_data = base64Utf8;
MimeType = mimeType;
}

/// <summary>
/// Creates an <see cref="AudioContentBlock"/> directly from base64-encoded UTF-8 bytes that are
/// already known to be valid, bypassing the validation the public <see cref="Data"/> setter performs.
/// </summary>
/// <remarks>
/// Used by <see cref="ContentBlock.Converter"/> for the JSON fast path: when the wire <c>data</c> value
/// required no unescaping, its raw UTF-8 bytes are exactly the base64 text as read from the document,
/// so re-validating them would be redundant.
/// </remarks>
internal static AudioContentBlock FromTrustedBase64Utf8(ReadOnlyMemory<byte> base64Utf8, string mimeType) =>
new(base64Utf8, mimeType, alreadyBase64Encoded: true);

/// <inheritdoc/>
public override string Type => "audio";

/// <summary>
/// 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"/>.
/// Setting this value will invalidate any cached value of <see cref="DecodedData"/>. The value must be
/// the base64 text representation of the data, as UTF-8 bytes — not the raw, unencoded bytes. To
/// construct an instance from raw binary data, use <see cref="FromBytes"/> instead.
/// </remarks>
/// <exception cref="ArgumentException">The assigned value is not valid base64-encoded UTF-8 data.</exception>
[JsonPropertyName("data")]
public required ReadOnlyMemory<byte> Data
{
Expand All @@ -595,6 +636,7 @@ public required ReadOnlyMemory<byte> Data
}
set
{
EncodingUtilities.ValidateBase64Utf8(value, nameof(Data), nameof(AudioContentBlock), nameof(FromBytes));
_data = value;
_decodedData = null; // Invalidate cache
}
Expand Down
10 changes: 3 additions & 7 deletions src/ModelContextProtocol.Core/Protocol/ResourceContents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,13 +139,9 @@ public sealed class Converter : JsonConverter<ResourceContents>

if (blob is not null)
{
return new BlobResourceContents
{
Uri = uri ?? string.Empty,
MimeType = mimeType,
Blob = blob.Value,
Meta = meta,
};
var blobResource = BlobResourceContents.FromTrustedBase64Utf8(blob.Value, uri ?? string.Empty, mimeType);
blobResource.Meta = meta;
return blobResource;
}

if (text is not null)
Expand Down
44 changes: 42 additions & 2 deletions tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,11 @@ public void ToolResultContentBlock_SerializationRoundTrip()
Content =
[
new TextContentBlock { Text = "Result data" },
new ImageContentBlock { Data = System.Text.Encoding.UTF8.GetBytes("base64data"), MimeType = "image/png" }
new ImageContentBlock
{
Data = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("base64data"))),
MimeType = "image/png",
}
],
StructuredContent = JsonElement.Parse("""{"temperature":18,"condition":"cloudy"}"""),
IsError = false
Expand All @@ -246,7 +250,7 @@ public void ToolResultContentBlock_SerializationRoundTrip()
var textBlock = Assert.IsType<TextContentBlock>(result.Content[0]);
Assert.Equal("Result data", textBlock.Text);
var imageBlock = Assert.IsType<ImageContentBlock>(result.Content[1]);
Assert.Equal("base64data", System.Text.Encoding.UTF8.GetString(imageBlock.Data.ToArray()));
Assert.Equal("base64data", System.Text.Encoding.UTF8.GetString(imageBlock.DecodedData.ToArray()));
Assert.Equal("image/png", imageBlock.MimeType);
Assert.NotNull(result.StructuredContent);
Assert.Equal(18, result.StructuredContent.Value.GetProperty("temperature").GetInt32());
Expand Down Expand Up @@ -292,6 +296,42 @@ public void AudioContentBlock_FromBytes_ThrowsForNullOrWhiteSpaceMimeType(string
Assert.ThrowsAny<ArgumentException>(() => AudioContentBlock.FromBytes((byte[])[1, 2, 3], mimeType!));
}

[Fact]
public void ImageContentBlock_DataSetter_ThrowsForRawUnencodedBytes()
{
// Assigning raw binary directly to Data (instead of going through FromBytes) must fail
// immediately and clearly, rather than silently producing a corrupt JSON payload that only
// fails to parse on the receiving end. Regression test for
// https://github.com/modelcontextprotocol/csharp-sdk/issues/1835.
byte[] rawBytes = new byte[900_000];
new Random(42).NextBytes(rawBytes);

var ex = Assert.Throws<ArgumentException>(() => new ImageContentBlock { Data = rawBytes, MimeType = "image/png" });
Assert.Equal("Data", ex.ParamName);
Assert.Contains("ImageContentBlock.FromBytes", ex.Message);
}

[Fact]
public void AudioContentBlock_DataSetter_ThrowsForRawUnencodedBytes()
{
byte[] rawBytes = new byte[900_000];
new Random(42).NextBytes(rawBytes);

var ex = Assert.Throws<ArgumentException>(() => new AudioContentBlock { Data = rawBytes, MimeType = "audio/wav" });
Assert.Equal("Data", ex.ParamName);
Assert.Contains("AudioContentBlock.FromBytes", ex.Message);
}

[Fact]
public void ImageContentBlock_DataSetter_ThrowsForOddLengthBase64()
{
// Well-formed base64 alphabet characters are not enough on their own; the length must also
// be consistent with padding rules.
var ex = Assert.Throws<ArgumentException>(() =>
new ImageContentBlock { Data = System.Text.Encoding.UTF8.GetBytes("YWJ"), MimeType = "image/png" });
Assert.Equal("Data", ex.ParamName);
}

[Fact]
public void ImageContentBlock_Deserialization_HandlesEscapedForwardSlashInBase64()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ public static void PromptMessage_SerializationRoundTrip_WithImageContent()
var original = new PromptMessage
{
Role = Role.Assistant,
Content = new ImageContentBlock { Data = System.Text.Encoding.UTF8.GetBytes("base64data"), MimeType = "image/png" }
Content = new ImageContentBlock
{
Data = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("base64data"))),
MimeType = "image/png",
}
};

string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
Expand All @@ -38,7 +42,7 @@ public static void PromptMessage_SerializationRoundTrip_WithImageContent()
Assert.NotNull(deserialized);
Assert.Equal(Role.Assistant, deserialized.Role);
var imageBlock = Assert.IsType<ImageContentBlock>(deserialized.Content);
Assert.Equal("base64data", System.Text.Encoding.UTF8.GetString(imageBlock.Data.ToArray()));
Assert.Equal("base64data", System.Text.Encoding.UTF8.GetString(imageBlock.DecodedData.ToArray()));
Assert.Equal("image/png", imageBlock.MimeType);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public static void ReadResourceResult_SerializationRoundTrip_PreservesAllPropert
{
Uri = "file:///image.png",
MimeType = "image/png",
Blob = System.Text.Encoding.UTF8.GetBytes("base64data")
Blob = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("base64data")))
}
],
Meta = new JsonObject { ["key"] = "value" }
Expand All @@ -43,7 +43,7 @@ public static void ReadResourceResult_SerializationRoundTrip_PreservesAllPropert
var blobContent = Assert.IsType<BlobResourceContents>(deserialized.Contents[1]);
Assert.Equal("file:///image.png", blobContent.Uri);
Assert.Equal("image/png", blobContent.MimeType);
Assert.Equal("base64data", System.Text.Encoding.UTF8.GetString(blobContent.Blob.ToArray()));
Assert.Equal("base64data", System.Text.Encoding.UTF8.GetString(blobContent.DecodedData.ToArray()));

Assert.NotNull(deserialized.Meta);
Assert.Equal("value", (string)deserialized.Meta["key"]!);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ public static void BlobResourceContents_NullMimeType_OmittedFromJson()
{
Uri = "file:///test.bin",
MimeType = null,
Blob = new byte[] { 1, 2, 3 }
Blob = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String(new byte[] { 1, 2, 3 }))
};

var json = JsonSerializer.Serialize<ResourceContents>(resource, McpJsonUtilities.DefaultOptions);
Expand All @@ -509,6 +509,21 @@ public static void BlobResourceContents_Deserialization_HandlesEscapedForwardSla
Assert.Equal(originalBytes, blob.DecodedData.ToArray());
}

[Fact]
public static void BlobResourceContents_BlobSetter_ThrowsForRawUnencodedBytes()
{
// Assigning raw binary directly to Blob (instead of going through FromBytes) must fail
// immediately and clearly, rather than silently producing a corrupt JSON payload that only
// fails to parse on the receiving end. Regression test for
// https://github.com/modelcontextprotocol/csharp-sdk/issues/1835.
byte[] rawBytes = new byte[900_000];
new Random(42).NextBytes(rawBytes);

var ex = Assert.Throws<ArgumentException>(() => new BlobResourceContents { Blob = rawBytes, Uri = "demo://files/1/test.pdf" });
Assert.Equal("Blob", ex.ParamName);
Assert.Contains("BlobResourceContents.FromBytes", ex.Message);
}

[Theory]
[MemberData(nameof(ContentBlockTests.Base64TestData), MemberType = typeof(ContentBlockTests))]
public static void BlobResourceContents_FromBytes_RoundtripsCorrectly(byte[] originalBytes)
Expand Down