Skip to content
Closed
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
36 changes: 29 additions & 7 deletions lib/src/types/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ String _readRequiredString(Object? value, String field) {
throw FormatException('$field must be a string');
}

bool _isAbsoluteUri(String value) {
return Uri.tryParse(value)?.hasScheme ?? false;
}

String _readRequiredAbsoluteUriString(Object? value, String field) {
final result = _readRequiredString(value, field);
if (!_isAbsoluteUri(result)) {
throw FormatException('$field must be an absolute URI');
}
return result;
}

void _validateAbsoluteUriString(String value, String field) {
if (!_isAbsoluteUri(value)) {
throw ArgumentError.value(value, field, 'must be an absolute URI');
}
}

String? _readOptionalPresentString(
Map<String, dynamic> json,
String key,
Expand Down Expand Up @@ -266,7 +284,7 @@ class McpIcon {
};

return McpIcon(
src: _readRequiredString(json['src'], 'McpIcon.src'),
src: _readRequiredAbsoluteUriString(json['src'], 'McpIcon.src'),
mimeType: _readOptionalPresentString(
json,
'mimeType',
Expand All @@ -277,12 +295,16 @@ class McpIcon {
);
}

Map<String, dynamic> toJson() => {
'src': src,
if (mimeType != null) 'mimeType': mimeType,
if (sizes != null) 'sizes': sizes,
if (theme != null) 'theme': theme!.name,
};
Map<String, dynamic> toJson() {
_validateAbsoluteUriString(src, 'McpIcon.src');

return {
'src': src,
if (mimeType != null) 'mimeType': mimeType,
if (sizes != null) 'sizes': sizes,
if (theme != null) 'theme': theme!.name,
};
}
}

/// Base class for content parts within prompts or tool results.
Expand Down
18 changes: 18 additions & 0 deletions test/types_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,11 @@ void main() {
'sizes': ['48x48', 'any'],
'theme': 'dark',
});

final dataIcon = McpIcon.fromJson({
'src': 'data:image/png;base64,aWNvbg==',
});
expect(dataIcon.src, equals('data:image/png;base64,aWNvbg=='));
});

test('McpIcon rejects malformed stable wire fields', () {
Expand All @@ -540,6 +545,8 @@ void main() {

expectInvalid({});
expectInvalid({'src': 1});
expectInvalid({'src': 'icon.png'});
expectInvalid({'src': '://not-a-uri'});
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});
Expand All @@ -553,6 +560,17 @@ void main() {
expectInvalid({'src': 'https://example.com/icon.png', 'theme': 'sepia'});
});

test('McpIcon validates src URI during serialization', () {
expect(
() => const McpIcon(src: 'icon.png').toJson(),
throwsA(isA<ArgumentError>()),
);
expect(
const McpIcon(src: 'data:image/png;base64,aWNvbg==').toJson()['src'],
equals('data:image/png;base64,aWNvbg=='),
);
});

test('Implementation icon parsing rejects invalid themes', () {
expect(
() => Implementation.fromJson({
Expand Down