diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index cb22fbf1..ce19f8da 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -21,6 +21,46 @@ Map _asJsonObject( return map; } +String _readRequiredString(Object? value, String field) { + if (value is String) { + return value; + } + throw FormatException('$field must be a string'); +} + +String? _readOptionalPresentString( + Map json, + String key, + String field, +) { + if (!json.containsKey(key)) { + return null; + } + return _readRequiredString(json[key], field); +} + +List? _readOptionalPresentStringList( + Map json, + String key, + String field, +) { + if (!json.containsKey(key)) { + return null; + } + final value = json[key]; + if (value is! List) { + throw FormatException('$field must be a list of strings'); + } + + return [ + for (final item in value) + if (item is String) + item + else + throw FormatException('$field items must be strings'), + ]; +} + /// Allowed audience values for content/resource annotations. enum AnnotationAudience { user, assistant } @@ -211,17 +251,28 @@ class McpIcon { }); factory McpIcon.fromJson(Map json) { - final themeString = json['theme'] as String?; + final themeString = _readOptionalPresentString( + json, + 'theme', + 'McpIcon.theme', + ); final iconTheme = switch (themeString) { 'light' => IconTheme.light, 'dark' => IconTheme.dark, - _ => null, + null => null, + _ => throw const FormatException( + 'McpIcon.theme must be either "light" or "dark"', + ), }; return McpIcon( - src: json['src'] as String, - mimeType: json['mimeType'] as String?, - sizes: (json['sizes'] as List?)?.cast(), + src: _readRequiredString(json['src'], 'McpIcon.src'), + mimeType: _readOptionalPresentString( + json, + 'mimeType', + 'McpIcon.mimeType', + ), + sizes: _readOptionalPresentStringList(json, 'sizes', 'McpIcon.sizes'), theme: iconTheme, ); } diff --git a/test/types_test.dart b/test/types_test.dart index 6c49b37b..798851d0 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -511,6 +511,64 @@ void main() { expect(deserialized.theme, equals('dark')); }); + test('McpIcon parses stable wire fields', () { + final icon = McpIcon.fromJson({ + 'src': 'https://example.com/icon.png', + 'mimeType': 'image/png', + 'sizes': ['48x48', 'any'], + 'theme': 'dark', + }); + + expect(icon.src, equals('https://example.com/icon.png')); + expect(icon.mimeType, equals('image/png')); + expect(icon.sizes, equals(['48x48', 'any'])); + expect(icon.theme, equals(IconTheme.dark)); + expect(icon.toJson(), { + 'src': 'https://example.com/icon.png', + 'mimeType': 'image/png', + 'sizes': ['48x48', 'any'], + 'theme': 'dark', + }); + }); + + test('McpIcon rejects malformed stable wire fields', () { + void expectInvalid( + Map json, + ) { + expect(() => McpIcon.fromJson(json), throwsA(isA())); + } + + expectInvalid({}); + expectInvalid({'src': 1}); + expectInvalid({'src': 'https://example.com/icon.png', 'mimeType': null}); + expectInvalid({'src': 'https://example.com/icon.png', 'mimeType': 1}); + expectInvalid({'src': 'https://example.com/icon.png', 'sizes': null}); + expectInvalid({'src': 'https://example.com/icon.png', 'sizes': '48x48'}); + expectInvalid({ + 'src': 'https://example.com/icon.png', + 'sizes': ['48x48', 1], + }); + expectInvalid({'src': 'https://example.com/icon.png', 'theme': null}); + expectInvalid({'src': 'https://example.com/icon.png', 'theme': 1}); + expectInvalid({'src': 'https://example.com/icon.png', 'theme': 'sepia'}); + }); + + test('Implementation icon parsing rejects invalid themes', () { + expect( + () => Implementation.fromJson({ + 'name': 'test-client', + 'version': '1.0.0', + 'icons': [ + { + 'src': 'https://example.com/icon.png', + 'theme': 'sepia', + }, + ], + }), + throwsA(isA()), + ); + }); + test('ImageContent supports annotations and meta', () { final content = const ImageContent( data: 'base64data',