diff --git a/CHANGELOG.md b/CHANGELOG.md index a6ade773..4e364cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,8 @@ and MCP 2026 `format: uri` schemas. - Rejected non-absolute resource URIs and malformed resource URI templates to match stable and MCP 2026 `format: uri` and `format: uri-template` schemas. +- Rejected malformed base64 payloads for image, audio, and blob resource + content to match stable and MCP 2026 `format: byte` schemas. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index b818711e..f733683a 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -51,6 +51,11 @@ String _absoluteUriForJson(String value, String field) { return value; } +String _base64ForJson(String value, String field) { + validateBase64String(value, field); + return value; +} + String? _readOptionalPresentString( Map json, String key, @@ -186,7 +191,10 @@ sealed class ResourceContents { return BlobResourceContents( uri: uri, mimeType: mimeType, - blob: json['blob'] as String, + blob: readRequiredBase64String( + json['blob'], + 'BlobResourceContents.blob', + ), meta: meta, extra: passthrough, ); @@ -205,7 +213,9 @@ sealed class ResourceContents { if (mimeType != null) 'mimeType': mimeType, ...switch (this) { final TextResourceContents c => {'text': c.text}, - final BlobResourceContents c => {'blob': c.blob}, + final BlobResourceContents c => { + 'blob': _base64ForJson(c.blob, 'BlobResourceContents.blob'), + }, UnknownResourceContents _ => {}, }, if (meta != null) @@ -346,14 +356,14 @@ sealed class Content { '_meta': readJsonObject(c.meta, 'TextContent._meta'), }, final ImageContent c => { - 'data': c.data, + 'data': _base64ForJson(c.data, 'ImageContent.data'), 'mimeType': c.mimeType, if (c.annotations != null) 'annotations': c.annotations!.toJson(), if (c.meta != null) '_meta': readJsonObject(c.meta, 'ImageContent._meta'), }, final AudioContent c => { - 'data': c.data, + 'data': _base64ForJson(c.data, 'AudioContent.data'), 'mimeType': c.mimeType, if (c.annotations != null) 'annotations': c.annotations!.toJson(), if (c.meta != null) @@ -448,7 +458,7 @@ class ImageContent extends Content { factory ImageContent.fromJson(Map json) { return ImageContent( - data: json['data'] as String, + data: readRequiredBase64String(json['data'], 'ImageContent.data'), mimeType: json['mimeType'] as String, theme: json['theme'] as String?, annotations: json['annotations'] == null @@ -483,7 +493,7 @@ class AudioContent extends Content { factory AudioContent.fromJson(Map json) { return AudioContent( - data: json['data'] as String, + data: readRequiredBase64String(json['data'], 'AudioContent.data'), mimeType: json['mimeType'] as String, annotations: json['annotations'] == null ? null diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index 27f883cd..116ba087 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -25,6 +25,11 @@ Map _asJsonObject( return map; } +String _base64ForJson(String value, String field) { + validateBase64String(value, field); + return value; +} + Object _parseSamplingMessageContent(dynamic value) { if (value is List) { return value @@ -295,7 +300,7 @@ sealed class SamplingContent { '_meta': readJsonObject(c.meta, 'SamplingTextContent._meta'), }, final SamplingImageContent c => { - 'data': c.data, + 'data': _base64ForJson(c.data, 'SamplingImageContent.data'), 'mimeType': c.mimeType, if (c.annotations != null) 'annotations': readJsonObject( @@ -306,7 +311,7 @@ sealed class SamplingContent { '_meta': readJsonObject(c.meta, 'SamplingImageContent._meta'), }, final SamplingAudioContent c => { - 'data': c.data, + 'data': _base64ForJson(c.data, 'SamplingAudioContent.data'), 'mimeType': c.mimeType, if (c.annotations != null) 'annotations': readJsonObject( @@ -396,7 +401,10 @@ class SamplingImageContent extends SamplingContent { factory SamplingImageContent.fromJson(Map json) => SamplingImageContent( - data: json['data'] as String, + data: readRequiredBase64String( + json['data'], + 'SamplingImageContent.data', + ), mimeType: json['mimeType'] as String, annotations: _asJsonObjectOrNull( json['annotations'], @@ -429,7 +437,10 @@ class SamplingAudioContent extends SamplingContent { factory SamplingAudioContent.fromJson(Map json) => SamplingAudioContent( - data: json['data'] as String, + data: readRequiredBase64String( + json['data'], + 'SamplingAudioContent.data', + ), mimeType: json['mimeType'] as String, annotations: _asJsonObjectOrNull( json['annotations'], diff --git a/lib/src/types/validation.dart b/lib/src/types/validation.dart index 5c140de0..a3455ab3 100644 --- a/lib/src/types/validation.dart +++ b/lib/src/types/validation.dart @@ -1,5 +1,11 @@ +import 'dart:convert'; + import '../shared/uri_template.dart'; +final _base64Pattern = RegExp( + r'^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$', +); + String readRequiredString(Object? value, String field) { if (value is String) { return value; @@ -49,6 +55,36 @@ void validateUriTemplateString(String value, String field) { } } +bool isBase64String(String value) { + if (!_base64Pattern.hasMatch(value)) { + return false; + } + try { + base64.decode(value); + return true; + } on FormatException { + return false; + } +} + +String readRequiredBase64String(Object? value, String field) { + final result = readRequiredString(value, field); + if (!isBase64String(result)) { + throw FormatException('$field must be a base64-encoded string'); + } + return result; +} + +void validateBase64String(String value, String field) { + if (!isBase64String(value)) { + throw ArgumentError.value( + value, + field, + 'must be a base64-encoded string', + ); + } +} + double? readUnitDouble(Object? value, String field) { final number = readOptionalFiniteNumber(value, field); final result = number?.toDouble(); diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index b1476619..db62d1b2 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -56,7 +56,8 @@ void main() { }); test('Icon Field Support', () { - final icon = const ImageContent(data: 'base64', mimeType: 'image/png'); + const iconData = 'YmFzZTY0'; + final icon = const ImageContent(data: iconData, mimeType: 'image/png'); final icons = [ const McpIcon( src: 'https://example.com/icon.png', @@ -71,12 +72,12 @@ void main() { icon: icon, icons: icons, ); - expect(tool.icon?.data, 'base64'); + expect(tool.icon?.data, iconData); expect(tool.toJson().containsKey('icon'), isFalse); expect((tool.toJson()['icons'] as List).first['theme'], 'dark'); expect( Tool.fromJson({...tool.toJson(), 'icon': icon.toJson()}).icon?.data, - 'base64', + iconData, ); final resource = Resource( @@ -85,14 +86,14 @@ void main() { icon: icon, icons: icons, ); - expect(resource.icon?.data, 'base64'); + expect(resource.icon?.data, iconData); expect(resource.toJson().containsKey('icon'), isFalse); expect((resource.toJson()['icons'] as List).first['theme'], 'dark'); expect( Resource.fromJson( {...resource.toJson(), 'icon': icon.toJson()}, ).icon?.data, - 'base64', + iconData, ); final prompt = Prompt( @@ -100,12 +101,12 @@ void main() { icon: icon, icons: icons, ); - expect(prompt.icon?.data, 'base64'); + expect(prompt.icon?.data, iconData); expect(prompt.toJson().containsKey('icon'), isFalse); expect((prompt.toJson()['icons'] as List).first['theme'], 'dark'); expect( Prompt.fromJson({...prompt.toJson(), 'icon': icon.toJson()}).icon?.data, - 'base64', + iconData, ); final template = ResourceTemplate( @@ -114,14 +115,14 @@ void main() { icon: icon, icons: icons, ); - expect(template.icon?.data, 'base64'); + expect(template.icon?.data, iconData); expect(template.toJson().containsKey('icon'), isFalse); expect((template.toJson()['icons'] as List).first['theme'], 'dark'); expect( ResourceTemplate.fromJson( {...template.toJson(), 'icon': icon.toJson()}, ).icon?.data, - 'base64', + iconData, ); }); @@ -347,7 +348,7 @@ void main() { test('McpServer Metadata Logic', () { final server = McpServer(const Implementation(name: 'test', version: '1.0')); - final icon = const ImageContent(data: 'data', mimeType: 'image/png'); + final icon = const ImageContent(data: 'ZGF0YQ==', mimeType: 'image/png'); // We can rely on the fact that we updated the code to pass it through. // Let's rely on the previous unit tests for `Tool` serialization, and here just ensure `McpServer` methods don't crash. diff --git a/test/types/resources_test.dart b/test/types/resources_test.dart index d21d709c..ed74be1b 100644 --- a/test/types/resources_test.dart +++ b/test/types/resources_test.dart @@ -76,7 +76,7 @@ void main() { 'mimeType': 'text/plain', 'icon': { 'type': 'image', - 'data': 'base64data', + 'data': 'YmFzZTY0ZGF0YQ==', 'mimeType': 'image/png', }, 'annotations': { @@ -98,7 +98,7 @@ void main() { expect(resource.description, equals('A test file resource')); expect(resource.mimeType, equals('text/plain')); expect(resource.icon, isNotNull); - expect(resource.icon!.data, equals('base64data')); + expect(resource.icon!.data, equals('YmFzZTY0ZGF0YQ==')); expect(resource.icons, isNotNull); expect( resource.icons!.single.src, @@ -225,7 +225,7 @@ void main() { 'mimeType': 'application/json', 'icon': { 'type': 'image', - 'data': 'icondata', + 'data': 'aWNvbmRhdGE=', 'mimeType': 'image/svg+xml', }, 'annotations': { diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index bb6b5831..ac863c5b 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -116,68 +116,110 @@ void main() { group('SamplingImageContent', () { test('constructs correctly', () { + const imageData = 'YmFzZTY0ZGF0YQ=='; const content = - SamplingImageContent(data: 'base64data', mimeType: 'image/png'); - expect(content.data, equals('base64data')); + SamplingImageContent(data: imageData, mimeType: 'image/png'); + expect(content.data, equals(imageData)); expect(content.mimeType, equals('image/png')); }); test('toJson serializes correctly', () { + const imageData = 'aW1nZGF0YQ=='; const content = - SamplingImageContent(data: 'imgdata', mimeType: 'image/jpeg'); + SamplingImageContent(data: imageData, mimeType: 'image/jpeg'); final json = content.toJson(); expect(json['type'], equals('image')); - expect(json['data'], equals('imgdata')); + expect(json['data'], equals(imageData)); expect(json['mimeType'], equals('image/jpeg')); }); test('fromJson parses correctly', () { + const imageData = 'ZW5jb2RlZA=='; final json = { 'type': 'image', - 'data': 'encoded', + 'data': imageData, 'mimeType': 'image/gif', }; final content = SamplingContent.fromJson(json); expect(content, isA()); final img = content as SamplingImageContent; - expect(img.data, equals('encoded')); + expect(img.data, equals(imageData)); expect(img.mimeType, equals('image/gif')); }); + + test('validates base64 byte data', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'image', + 'data': 'not base64!', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); + expect( + () => const SamplingImageContent( + data: 'not base64!', + mimeType: 'image/png', + ).toJson(), + throwsA(isA()), + ); + }); }); group('SamplingAudioContent', () { test('constructs correctly', () { + const audioData = 'YmFzZTY0YXVkaW8='; const content = SamplingAudioContent( - data: 'base64audio', + data: audioData, mimeType: 'audio/wav', ); - expect(content.data, equals('base64audio')); + expect(content.data, equals(audioData)); expect(content.mimeType, equals('audio/wav')); }); test('toJson serializes correctly', () { + const audioData = 'YXVkaW8tZGF0YQ=='; const content = SamplingAudioContent( - data: 'audio-data', + data: audioData, mimeType: 'audio/mpeg', ); final json = content.toJson(); expect(json['type'], equals('audio')); - expect(json['data'], equals('audio-data')); + expect(json['data'], equals(audioData)); expect(json['mimeType'], equals('audio/mpeg')); }); test('fromJson parses correctly', () { + const audioData = 'ZW5jb2RlZC1hdWRpbw=='; final json = { 'type': 'audio', - 'data': 'encoded-audio', + 'data': audioData, 'mimeType': 'audio/ogg', }; final content = SamplingContent.fromJson(json); expect(content, isA()); final audio = content as SamplingAudioContent; - expect(audio.data, equals('encoded-audio')); + expect(audio.data, equals(audioData)); expect(audio.mimeType, equals('audio/ogg')); }); + + test('validates base64 byte data', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'audio', + 'data': 'not base64!', + 'mimeType': 'audio/wav', + }), + throwsA(isA()), + ); + expect( + () => const SamplingAudioContent( + data: 'not base64!', + mimeType: 'audio/wav', + ).toJson(), + throwsA(isA()), + ); + }); }); group('SamplingToolUseContent', () { diff --git a/test/types_test.dart b/test/types_test.dart index e54900a0..a6e304af 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -482,21 +482,22 @@ void main() { }); test('ImageContent serialization and deserialization', () { + const imageData = 'YmFzZTY0ZGF0YQ=='; final content = - const ImageContent(data: 'base64data', mimeType: 'image/png'); + const ImageContent(data: imageData, mimeType: 'image/png'); final json = content.toJson(); expect(json['type'], equals('image')); - expect(json['data'], equals('base64data')); + expect(json['data'], equals(imageData)); expect(json['mimeType'], equals('image/png')); final deserialized = ImageContent.fromJson(json); - expect(deserialized.data, equals('base64data')); + expect(deserialized.data, equals(imageData)); expect(deserialized.mimeType, equals('image/png')); }); test('ImageContent parses legacy theme without serializing it', () { final content = const ImageContent( - data: 'base64data', + data: 'YmFzZTY0ZGF0YQ==', mimeType: 'image/png', theme: 'dark', ); @@ -511,6 +512,32 @@ void main() { expect(deserialized.theme, equals('dark')); }); + test('ImageContent validates base64 byte data', () { + expect( + () => ImageContent.fromJson({ + 'type': 'image', + 'data': 'not base64!', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); + expect( + () => ImageContent.fromJson({ + 'type': 'image', + 'data': 'a-b_', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); + expect( + () => const ImageContent( + data: 'not base64!', + mimeType: 'image/png', + ).toJson(), + throwsA(isA()), + ); + }); + test('McpIcon parses stable wire fields', () { final icon = McpIcon.fromJson({ 'src': 'https://example.com/icon.png', @@ -684,7 +711,7 @@ void main() { test('ImageContent supports annotations and meta', () { final content = const ImageContent( - data: 'base64data', + data: 'YmFzZTY0ZGF0YQ==', mimeType: 'image/png', annotations: Annotations( audience: [AnnotationAudience.user], @@ -704,21 +731,22 @@ void main() { }); test('AudioContent serialization and deserialization', () { + const audioData = 'YmFzZTY0ZGF0YQ=='; final content = - const AudioContent(data: 'base64data', mimeType: 'audio/wav'); + const AudioContent(data: audioData, mimeType: 'audio/wav'); final json = content.toJson(); expect(json['type'], equals('audio')); - expect(json['data'], equals('base64data')); + expect(json['data'], equals(audioData)); expect(json['mimeType'], equals('audio/wav')); final deserialized = AudioContent.fromJson(json); - expect(deserialized.data, equals('base64data')); + expect(deserialized.data, equals(audioData)); expect(deserialized.mimeType, equals('audio/wav')); }); test('AudioContent supports annotations and meta', () { final content = const AudioContent( - data: 'base64data', + data: 'YmFzZTY0ZGF0YQ==', mimeType: 'audio/wav', annotations: Annotations(priority: 0.3), meta: { @@ -735,6 +763,24 @@ void main() { expect(deserialized.meta?['traceId'], equals('audio-1')); }); + test('AudioContent validates base64 byte data', () { + expect( + () => AudioContent.fromJson({ + 'type': 'audio', + 'data': 'not base64!', + 'mimeType': 'audio/wav', + }), + throwsA(isA()), + ); + expect( + () => const AudioContent( + data: 'not base64!', + mimeType: 'audio/wav', + ).toJson(), + throwsA(isA()), + ); + }); + test('UnknownContent serialization and deserialization', () { final content = const UnknownContent(type: 'unknown'); final json = content.toJson(); @@ -910,21 +956,39 @@ void main() { }); test('BlobResourceContents serialization and deserialization', () { + const blobData = 'YmFzZTY0ZGF0YQ=='; final contents = const BlobResourceContents( uri: 'file://example.bin', - blob: 'base64data', + blob: blobData, mimeType: 'application/octet-stream', ); final json = contents.toJson(); expect(json['uri'], equals('file://example.bin')); - expect(json['blob'], equals('base64data')); + expect(json['blob'], equals(blobData)); expect(json['mimeType'], equals('application/octet-stream')); final deserialized = ResourceContents.fromJson(json) as BlobResourceContents; expect(deserialized.uri, equals('file://example.bin')); - expect(deserialized.blob, equals('base64data')); + expect(deserialized.blob, equals(blobData)); + }); + + test('BlobResourceContents validates base64 byte data', () { + expect( + () => ResourceContents.fromJson({ + 'uri': 'file://example.bin', + 'blob': 'not base64!', + }), + throwsA(isA()), + ); + expect( + () => const BlobResourceContents( + uri: 'file://example.bin', + blob: 'not base64!', + ).toJson(), + throwsA(isA()), + ); }); test('ResourceContents rejects non-JSON metadata and passthrough maps', () {