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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 16 additions & 6 deletions lib/src/types/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic> json,
String key,
Expand Down Expand Up @@ -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,
);
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -448,7 +458,7 @@ class ImageContent extends Content {

factory ImageContent.fromJson(Map<String, dynamic> 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
Expand Down Expand Up @@ -483,7 +493,7 @@ class AudioContent extends Content {

factory AudioContent.fromJson(Map<String, dynamic> json) {
return AudioContent(
data: json['data'] as String,
data: readRequiredBase64String(json['data'], 'AudioContent.data'),
mimeType: json['mimeType'] as String,
annotations: json['annotations'] == null
? null
Expand Down
19 changes: 15 additions & 4 deletions lib/src/types/sampling.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ Map<String, dynamic> _asJsonObject(
return map;
}

String _base64ForJson(String value, String field) {
validateBase64String(value, field);
return value;
}

Object _parseSamplingMessageContent(dynamic value) {
if (value is List) {
return value
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -396,7 +401,10 @@ class SamplingImageContent extends SamplingContent {

factory SamplingImageContent.fromJson(Map<String, dynamic> json) =>
SamplingImageContent(
data: json['data'] as String,
data: readRequiredBase64String(
json['data'],
'SamplingImageContent.data',
),
mimeType: json['mimeType'] as String,
annotations: _asJsonObjectOrNull(
json['annotations'],
Expand Down Expand Up @@ -429,7 +437,10 @@ class SamplingAudioContent extends SamplingContent {

factory SamplingAudioContent.fromJson(Map<String, dynamic> json) =>
SamplingAudioContent(
data: json['data'] as String,
data: readRequiredBase64String(
json['data'],
'SamplingAudioContent.data',
),
mimeType: json['mimeType'] as String,
annotations: _asJsonObjectOrNull(
json['annotations'],
Expand Down
36 changes: 36 additions & 0 deletions lib/src/types/validation.dart
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
Expand Down
21 changes: 11 additions & 10 deletions test/mcp_2025_11_25_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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(
Expand All @@ -85,27 +86,27 @@ 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(
name: 'test-prompt',
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(
Expand All @@ -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,
);
});

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions test/types/resources_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ void main() {
'mimeType': 'text/plain',
'icon': {
'type': 'image',
'data': 'base64data',
'data': 'YmFzZTY0ZGF0YQ==',
'mimeType': 'image/png',
},
'annotations': {
Expand All @@ -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,
Expand Down Expand Up @@ -225,7 +225,7 @@ void main() {
'mimeType': 'application/json',
'icon': {
'type': 'image',
'data': 'icondata',
'data': 'aWNvbmRhdGE=',
'mimeType': 'image/svg+xml',
},
'annotations': {
Expand Down
Loading