Skip to content
Merged
38 changes: 26 additions & 12 deletions src/ModelContextProtocol.Core/Protocol/ContentBlock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ public sealed class Converter : JsonConverter<ContentBlock>
string? name = null;
string? title = null;
ReadOnlyMemory<byte>? data = null;
ReadOnlyMemory<byte>? decodedData = null;
string? mimeType = null;
string? uri = null;
string? description = null;
Expand Down Expand Up @@ -137,7 +138,14 @@ public sealed class Converter : JsonConverter<ContentBlock>
break;

case "data":
data = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
if (!reader.ValueIsEscaped)
{
data = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
}
else
{
decodedData = reader.GetBytesFromBase64();
}
break;

case "mimeType":
Expand Down Expand Up @@ -230,17 +238,23 @@ public sealed class Converter : JsonConverter<ContentBlock>
Text = text ?? throw new JsonException("Text contents must be provided for 'text' type."),
},

"image" => 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."),
},

"audio" => 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."),
},
"image" => decodedData is not null ?
ImageContentBlock.FromBytes(decodedData.Value,
Comment thread
stephentoub marked this conversation as resolved.
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."),
},

"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."),
},

"resource" => new EmbeddedResourceBlock
{
Expand Down
17 changes: 16 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/ResourceContents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ public sealed class Converter : JsonConverter<ResourceContents>
string? uri = null;
string? mimeType = null;
ReadOnlyMemory<byte>? blob = null;
ReadOnlyMemory<byte>? decodedBlob = null;
string? text = null;
JsonObject? meta = null;

Expand All @@ -105,7 +106,14 @@ public sealed class Converter : JsonConverter<ResourceContents>
break;

case "blob":
blob = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
if (!reader.ValueIsEscaped)
{
blob = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
}
else
{
decodedBlob = reader.GetBytesFromBase64();
}
break;

case "text":
Expand All @@ -122,6 +130,13 @@ public sealed class Converter : JsonConverter<ResourceContents>
}
}

if (decodedBlob is not null)
{
var blobResource = BlobResourceContents.FromBytes(decodedBlob.Value, uri ?? string.Empty, mimeType);
blobResource.Meta = meta;
return blobResource;
}

if (blob is not null)
{
return new BlobResourceContents
Expand Down
33 changes: 33 additions & 0 deletions tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -290,4 +290,37 @@ public void AudioContentBlock_FromBytes_ThrowsForNullOrWhiteSpaceMimeType(string
{
Assert.ThrowsAny<ArgumentException>(() => AudioContentBlock.FromBytes((byte[])[1, 2, 3], mimeType!));
}

[Fact]
public void ImageContentBlock_Deserialization_HandlesEscapedForwardSlashInBase64()
{
// Base64 uses '/' which some JSON encoders escape as '\/' (valid JSON).
// The converter must unescape before storing the base64 UTF-8 bytes.
byte[] originalBytes = [0xFF, 0xD8, 0xFF, 0xE0]; // sample bytes that produce '/' in base64
string base64 = Convert.ToBase64String(originalBytes); // "/9j/4A=="
Assert.Contains("/", base64);

// Simulate a JSON encoder that escapes '/' as '\/'
string json = $$"""{"type":"image","data":"{{base64.Replace("/", "\\/")}}","mimeType":"image/jpeg"}""";

var deserialized = JsonSerializer.Deserialize<ContentBlock>(json, McpJsonUtilities.DefaultOptions);
var image = Assert.IsType<ImageContentBlock>(deserialized);
Assert.Equal(base64, System.Text.Encoding.UTF8.GetString(image.Data.ToArray()));
Assert.Equal(originalBytes, image.DecodedData.ToArray());
}

[Fact]
public void AudioContentBlock_Deserialization_HandlesEscapedForwardSlashInBase64()
{
byte[] originalBytes = [0xFF, 0xD8, 0xFF, 0xE0];
string base64 = Convert.ToBase64String(originalBytes);
Assert.Contains("/", base64);

string json = $$"""{"type":"audio","data":"{{base64.Replace("/", "\\/")}}","mimeType":"audio/wav"}""";

var deserialized = JsonSerializer.Deserialize<ContentBlock>(json, McpJsonUtilities.DefaultOptions);
var audio = Assert.IsType<AudioContentBlock>(deserialized);
Assert.Equal(base64, System.Text.Encoding.UTF8.GetString(audio.Data.ToArray()));
Assert.Equal(originalBytes, audio.DecodedData.ToArray());
}
}
18 changes: 18 additions & 0 deletions tests/ModelContextProtocol.Tests/Protocol/ResourceContentsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -489,4 +489,22 @@ public static void BlobResourceContents_NullMimeType_OmittedFromJson()

Assert.DoesNotContain("mimeType", json);
}

[Fact]
public static void BlobResourceContents_Deserialization_HandlesEscapedForwardSlashInBase64()
{
// Base64 uses '/' which some JSON encoders escape as '\/' (valid JSON).
// The converter must unescape before storing the base64 UTF-8 bytes.
byte[] originalBytes = [0xFF, 0xD8, 0xFF, 0xE0]; // sample bytes that produce '/' in base64
string base64 = Convert.ToBase64String(originalBytes); // "/9j/4A=="
Assert.Contains("/", base64);

// Simulate a JSON encoder that escapes '/' as '\/'
string json = $$"""{"uri":"file:///test.bin","blob":"{{base64.Replace("/", "\\/")}}","mimeType":"application/octet-stream"}""";

var deserialized = JsonSerializer.Deserialize<ResourceContents>(json, McpJsonUtilities.DefaultOptions);
var blob = Assert.IsType<BlobResourceContents>(deserialized);
Assert.Equal(base64, System.Text.Encoding.UTF8.GetString(blob.Blob.ToArray()));
Assert.Equal(originalBytes, blob.DecodedData.ToArray());
}
}