Skip to content

Commit 774a1db

Browse files
Copilotstephentoub
andauthored
Fix base64 deserialization when JSON encoder escapes forward slashes (#1342)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent 74622d0 commit 774a1db

File tree

6 files changed

+710
-48
lines changed

6 files changed

+710
-48
lines changed

src/ModelContextProtocol.Core/Protocol/BlobResourceContents.cs

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Buffers;
22
using System.Buffers.Text;
33
using System.Diagnostics;
4+
using System.Diagnostics.CodeAnalysis;
45
using System.Runtime.InteropServices;
56
using System.Text.Json.Serialization;
67

@@ -28,7 +29,7 @@ namespace ModelContextProtocol.Protocol;
2829
public sealed class BlobResourceContents : ResourceContents
2930
{
3031
private ReadOnlyMemory<byte>? _decodedData;
31-
private ReadOnlyMemory<byte> _blob;
32+
private ReadOnlyMemory<byte>? _blob;
3233

3334
/// <summary>
3435
/// Creates an <see cref="BlobResourceContents"/> from raw data.
@@ -40,15 +41,20 @@ public sealed class BlobResourceContents : ResourceContents
4041
/// <exception cref="InvalidOperationException"></exception>
4142
public static BlobResourceContents FromBytes(ReadOnlyMemory<byte> bytes, string uri, string? mimeType = null)
4243
{
43-
ReadOnlyMemory<byte> blob = EncodingUtilities.EncodeToBase64Utf8(bytes);
44-
45-
return new()
46-
{
47-
_decodedData = bytes,
48-
Blob = blob,
49-
MimeType = mimeType,
50-
Uri = uri
51-
};
44+
return new(bytes, uri, mimeType);
45+
}
46+
47+
/// <summary>Initializes a new instance of the <see cref="BlobResourceContents"/> class.</summary>
48+
public BlobResourceContents()
49+
{
50+
}
51+
52+
[SetsRequiredMembers]
53+
private BlobResourceContents(ReadOnlyMemory<byte> decodedData, string uri, string? mimeType)
54+
{
55+
_decodedData = decodedData;
56+
Uri = uri;
57+
MimeType = mimeType;
5258
}
5359

5460
/// <summary>
@@ -60,7 +66,16 @@ public static BlobResourceContents FromBytes(ReadOnlyMemory<byte> bytes, string
6066
[JsonPropertyName("blob")]
6167
public required ReadOnlyMemory<byte> Blob
6268
{
63-
get => _blob;
69+
get
70+
{
71+
if (_blob is null)
72+
{
73+
Debug.Assert(_decodedData is not null);
74+
_blob = EncodingUtilities.EncodeToBase64Utf8(_decodedData!.Value);
75+
}
76+
77+
return _blob.Value;
78+
}
6479
set
6580
{
6681
_blob = value;

src/ModelContextProtocol.Core/Protocol/ContentBlock.cs

Lines changed: 92 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ public sealed class Converter : JsonConverter<ContentBlock>
9292
string? name = null;
9393
string? title = null;
9494
ReadOnlyMemory<byte>? data = null;
95+
ReadOnlyMemory<byte>? decodedData = null;
9596
string? mimeType = null;
9697
string? uri = null;
9798
string? description = null;
@@ -137,7 +138,14 @@ public sealed class Converter : JsonConverter<ContentBlock>
137138
break;
138139

139140
case "data":
140-
data = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
141+
if (!reader.ValueIsEscaped)
142+
{
143+
data = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
144+
}
145+
else
146+
{
147+
decodedData = reader.GetBytesFromBase64();
148+
}
141149
break;
142150

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

233-
"image" => new ImageContentBlock
234-
{
235-
Data = data ?? throw new JsonException("Image data must be provided for 'image' type."),
236-
MimeType = mimeType ?? throw new JsonException("MIME type must be provided for 'image' type."),
237-
},
238-
239-
"audio" => new AudioContentBlock
240-
{
241-
Data = data ?? throw new JsonException("Audio data must be provided for 'audio' type."),
242-
MimeType = mimeType ?? throw new JsonException("MIME type must be provided for 'audio' type."),
243-
},
241+
"image" => decodedData is not null ?
242+
ImageContentBlock.FromBytes(decodedData.Value,
243+
mimeType ?? throw new JsonException("MIME type must be provided for 'image' type.")) :
244+
new ImageContentBlock
245+
{
246+
Data = data ?? throw new JsonException("Image data must be provided for 'image' type."),
247+
MimeType = mimeType ?? throw new JsonException("MIME type must be provided for 'image' type."),
248+
},
249+
250+
"audio" => decodedData is not null ?
251+
AudioContentBlock.FromBytes(decodedData.Value,
252+
mimeType ?? throw new JsonException("MIME type must be provided for 'audio' type.")) :
253+
new AudioContentBlock
254+
{
255+
Data = data ?? throw new JsonException("Audio data must be provided for 'audio' type."),
256+
MimeType = mimeType ?? throw new JsonException("MIME type must be provided for 'audio' type."),
257+
},
244258

245259
"resource" => new EmbeddedResourceBlock
246260
{
@@ -414,7 +428,7 @@ public sealed class TextContentBlock : ContentBlock
414428
public sealed class ImageContentBlock : ContentBlock
415429
{
416430
private ReadOnlyMemory<byte>? _decodedData;
417-
private ReadOnlyMemory<byte> _data;
431+
private ReadOnlyMemory<byte>? _data;
418432

419433
/// <summary>
420434
/// Creates an <see cref="ImageContentBlock"/> from decoded image bytes.
@@ -423,22 +437,27 @@ public sealed class ImageContentBlock : ContentBlock
423437
/// <param name="mimeType">The MIME type of the image.</param>
424438
/// <returns>A new <see cref="ImageContentBlock"/> instance.</returns>
425439
/// <remarks>
426-
/// This method stores the provided bytes as <see cref="DecodedData"/> and encodes them to base64 UTF-8 bytes for <see cref="Data"/>.
440+
/// This method stores the provided bytes as <see cref="DecodedData"/> and lazily encodes them to base64 UTF-8 bytes for <see cref="Data"/>.
427441
/// </remarks>
428442
/// <exception cref="ArgumentNullException"><paramref name="mimeType"/> is <see langword="null"/>.</exception>
429443
/// <exception cref="ArgumentException"><paramref name="mimeType"/> is empty or composed entirely of whitespace.</exception>
430444
public static ImageContentBlock FromBytes(ReadOnlyMemory<byte> bytes, string mimeType)
431445
{
432446
Throw.IfNullOrWhiteSpace(mimeType);
433447

434-
ReadOnlyMemory<byte> data = EncodingUtilities.EncodeToBase64Utf8(bytes);
435-
436-
return new()
437-
{
438-
_decodedData = bytes,
439-
Data = data,
440-
MimeType = mimeType
441-
};
448+
return new(bytes, mimeType);
449+
}
450+
451+
/// <summary>Initializes a new instance of the <see cref="ImageContentBlock"/> class.</summary>
452+
public ImageContentBlock()
453+
{
454+
}
455+
456+
[SetsRequiredMembers]
457+
private ImageContentBlock(ReadOnlyMemory<byte> decodedData, string mimeType)
458+
{
459+
_decodedData = decodedData;
460+
MimeType = mimeType;
442461
}
443462

444463
/// <inheritdoc/>
@@ -453,7 +472,16 @@ public static ImageContentBlock FromBytes(ReadOnlyMemory<byte> bytes, string mim
453472
[JsonPropertyName("data")]
454473
public required ReadOnlyMemory<byte> Data
455474
{
456-
get => _data;
475+
get
476+
{
477+
if (_data is null)
478+
{
479+
Debug.Assert(_decodedData is not null);
480+
_data = EncodingUtilities.EncodeToBase64Utf8(_decodedData!.Value);
481+
}
482+
483+
return _data.Value;
484+
}
457485
set
458486
{
459487
_data = value;
@@ -494,15 +522,22 @@ public ReadOnlyMemory<byte> DecodedData
494522
public required string MimeType { get; set; }
495523

496524
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
497-
private string DebuggerDisplay => $"MimeType = {MimeType}, Length = {DebuggerDisplayHelper.GetBase64LengthDisplay(Data)}";
525+
private string DebuggerDisplay
526+
{
527+
get
528+
{
529+
string lengthDisplay = _decodedData is not null ? $"{_decodedData.Value.Length} bytes" : DebuggerDisplayHelper.GetBase64LengthDisplay(Data);
530+
return $"MimeType = {MimeType}, Length = {lengthDisplay}";
531+
}
532+
}
498533
}
499534

500535
/// <summary>Represents audio provided to or from an LLM.</summary>
501536
[DebuggerDisplay("{DebuggerDisplay,nq}")]
502537
public sealed class AudioContentBlock : ContentBlock
503538
{
504539
private ReadOnlyMemory<byte>? _decodedData;
505-
private ReadOnlyMemory<byte> _data;
540+
private ReadOnlyMemory<byte>? _data;
506541

507542
/// <summary>
508543
/// Creates an <see cref="AudioContentBlock"/> from decoded audio bytes.
@@ -511,22 +546,27 @@ public sealed class AudioContentBlock : ContentBlock
511546
/// <param name="mimeType">The MIME type of the audio.</param>
512547
/// <returns>A new <see cref="AudioContentBlock"/> instance.</returns>
513548
/// <remarks>
514-
/// This method stores the provided bytes as <see cref="DecodedData"/> and encodes them to base64 UTF-8 bytes for <see cref="Data"/>.
549+
/// This method stores the provided bytes as <see cref="DecodedData"/> and lazily encodes them to base64 UTF-8 bytes for <see cref="Data"/>.
515550
/// </remarks>
516551
/// <exception cref="ArgumentNullException"><paramref name="mimeType"/> is <see langword="null"/>.</exception>
517552
/// <exception cref="ArgumentException"><paramref name="mimeType"/> is empty or composed entirely of whitespace.</exception>
518553
public static AudioContentBlock FromBytes(ReadOnlyMemory<byte> bytes, string mimeType)
519554
{
520555
Throw.IfNullOrWhiteSpace(mimeType);
521556

522-
ReadOnlyMemory<byte> data = EncodingUtilities.EncodeToBase64Utf8(bytes);
523-
524-
return new()
525-
{
526-
_decodedData = bytes,
527-
Data = data,
528-
MimeType = mimeType
529-
};
557+
return new(bytes, mimeType);
558+
}
559+
560+
/// <summary>Initializes a new instance of the <see cref="AudioContentBlock"/> class.</summary>
561+
public AudioContentBlock()
562+
{
563+
}
564+
565+
[SetsRequiredMembers]
566+
private AudioContentBlock(ReadOnlyMemory<byte> decodedData, string mimeType)
567+
{
568+
_decodedData = decodedData;
569+
MimeType = mimeType;
530570
}
531571

532572
/// <inheritdoc/>
@@ -541,7 +581,16 @@ public static AudioContentBlock FromBytes(ReadOnlyMemory<byte> bytes, string mim
541581
[JsonPropertyName("data")]
542582
public required ReadOnlyMemory<byte> Data
543583
{
544-
get => _data;
584+
get
585+
{
586+
if (_data is null)
587+
{
588+
Debug.Assert(_decodedData is not null);
589+
_data = EncodingUtilities.EncodeToBase64Utf8(_decodedData!.Value);
590+
}
591+
592+
return _data.Value;
593+
}
545594
set
546595
{
547596
_data = value;
@@ -582,7 +631,14 @@ public ReadOnlyMemory<byte> DecodedData
582631
public required string MimeType { get; set; }
583632

584633
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
585-
private string DebuggerDisplay => $"MimeType = {MimeType}, Length = {DebuggerDisplayHelper.GetBase64LengthDisplay(Data)}";
634+
private string DebuggerDisplay
635+
{
636+
get
637+
{
638+
string lengthDisplay = _decodedData is not null ? $"{_decodedData.Value.Length} bytes" : DebuggerDisplayHelper.GetBase64LengthDisplay(Data);
639+
return $"MimeType = {MimeType}, Length = {lengthDisplay}";
640+
}
641+
}
586642
}
587643

588644
/// <summary>Represents the contents of a resource, embedded into a prompt or tool call result.</summary>

src/ModelContextProtocol.Core/Protocol/ResourceContents.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ public sealed class Converter : JsonConverter<ResourceContents>
8080
string? uri = null;
8181
string? mimeType = null;
8282
ReadOnlyMemory<byte>? blob = null;
83+
ReadOnlyMemory<byte>? decodedBlob = null;
8384
string? text = null;
8485
JsonObject? meta = null;
8586

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

107108
case "blob":
108-
blob = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
109+
if (!reader.ValueIsEscaped)
110+
{
111+
blob = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
112+
}
113+
else
114+
{
115+
decodedBlob = reader.GetBytesFromBase64();
116+
}
109117
break;
110118

111119
case "text":
@@ -122,6 +130,13 @@ public sealed class Converter : JsonConverter<ResourceContents>
122130
}
123131
}
124132

133+
if (decodedBlob is not null)
134+
{
135+
var blobResource = BlobResourceContents.FromBytes(decodedBlob.Value, uri ?? string.Empty, mimeType);
136+
blobResource.Meta = meta;
137+
return blobResource;
138+
}
139+
125140
if (blob is not null)
126141
{
127142
return new BlobResourceContents

0 commit comments

Comments
 (0)