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 @@ -107,6 +107,8 @@
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 malformed shared annotation fields, including non-role audiences,
out-of-range priorities, and non-string `lastModified` values.
- 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
28 changes: 22 additions & 6 deletions lib/src/types/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ String _base64ForJson(String value, String field) {
return value;
}

Map<String, dynamic> _annotationsForJson(
Map<String, dynamic> value,
String field,
) {
final result = readJsonObject(value, field);
validateAnnotationsObject(result, field);
return result;
}

String? _readOptionalPresentString(
Map<String, dynamic> json,
String key,
Expand Down Expand Up @@ -113,12 +122,19 @@ class Annotations {
);

factory Annotations.fromJson(Map<String, dynamic> json) {
final audience = readOptionalAnnotationAudience(
json['audience'],
'Annotations.audience',
);
return Annotations(
audience: (json['audience'] as List<dynamic>?)
?.map((value) => AnnotationAudience.values.byName(value as String))
audience: audience
?.map((value) => AnnotationAudience.values.byName(value))
.toList(),
priority: readUnitDouble(json['priority'], 'Annotations.priority'),
lastModified: json['lastModified'] as String?,
lastModified: readOptionalString(
json['lastModified'],
'Annotations.lastModified',
),
);
}

Expand Down Expand Up @@ -379,8 +395,8 @@ sealed class Content {
if (c.icons != null)
'icons': c.icons!.map((icon) => icon.toJson()).toList(),
if (c.annotations != null)
'annotations': readJsonObject(
c.annotations,
'annotations': _annotationsForJson(
c.annotations!,
'ResourceLink.annotations',
),
if (c.meta != null)
Expand Down Expand Up @@ -596,7 +612,7 @@ class ResourceLink extends Content {
icons: (json['icons'] as List<dynamic>?)
?.map((icon) => McpIcon.fromJson(_asJsonObject(icon)))
.toList(),
annotations: _asJsonObjectOrNull(
annotations: readOptionalAnnotationsObject(
json['annotations'],
'ResourceLink.annotations',
),
Expand Down
21 changes: 17 additions & 4 deletions lib/src/types/resources.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,24 @@ class ResourceAnnotations {
factory ResourceAnnotations.fromJson(Map<String, dynamic> json) {
return ResourceAnnotations(
title: json['title'] as String?,
audience: (json['audience'] as List<dynamic>?)?.cast<String>(),
audience: readOptionalAnnotationAudience(
json['audience'],
'ResourceAnnotations.audience',
),
priority:
readUnitDouble(json['priority'], 'ResourceAnnotations.priority'),
lastModified: json['lastModified'] as String?,
lastModified: readOptionalString(
json['lastModified'],
'ResourceAnnotations.lastModified',
),
);
}

Map<String, dynamic> toJson() {
validateAnnotationAudience(
audience,
'ResourceAnnotations.audience',
);
validateUnitDouble(priority, 'ResourceAnnotations.priority');
return {
if (audience != null) 'audience': audience,
Expand Down Expand Up @@ -114,7 +124,7 @@ class Resource {
size: readOptionalInteger(json['size'], 'Resource.size'),
annotations: json['annotations'] != null
? ResourceAnnotations.fromJson(
json['annotations'] as Map<String, dynamic>,
readJsonObject(json['annotations'], 'Resource.annotations'),
)
: null,
meta: readOptionalJsonObject(json['_meta'], 'Resource._meta'),
Expand Down Expand Up @@ -202,7 +212,10 @@ class ResourceTemplate {
.toList(),
annotations: json['annotations'] != null
? ResourceAnnotations.fromJson(
json['annotations'] as Map<String, dynamic>,
readJsonObject(
json['annotations'],
'ResourceTemplate.annotations',
),
)
: null,
meta: readOptionalJsonObject(json['_meta'], 'ResourceTemplate._meta'),
Expand Down
27 changes: 18 additions & 9 deletions lib/src/types/sampling.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ String _base64ForJson(String value, String field) {
return value;
}

Map<String, dynamic> _annotationsForJson(
Map<String, dynamic> value,
String field,
) {
final result = readJsonObject(value, field);
validateAnnotationsObject(result, field);
return result;
}

Object _parseSamplingMessageContent(dynamic value) {
if (value is List) {
return value
Expand Down Expand Up @@ -292,8 +301,8 @@ sealed class SamplingContent {
final SamplingTextContent c => {
'text': c.text,
if (c.annotations != null)
'annotations': readJsonObject(
c.annotations,
'annotations': _annotationsForJson(
c.annotations!,
'SamplingTextContent.annotations',
),
if (c.meta != null)
Expand All @@ -303,8 +312,8 @@ sealed class SamplingContent {
'data': _base64ForJson(c.data, 'SamplingImageContent.data'),
'mimeType': c.mimeType,
if (c.annotations != null)
'annotations': readJsonObject(
c.annotations,
'annotations': _annotationsForJson(
c.annotations!,
'SamplingImageContent.annotations',
),
if (c.meta != null)
Expand All @@ -314,8 +323,8 @@ sealed class SamplingContent {
'data': _base64ForJson(c.data, 'SamplingAudioContent.data'),
'mimeType': c.mimeType,
if (c.annotations != null)
'annotations': readJsonObject(
c.annotations,
'annotations': _annotationsForJson(
c.annotations!,
'SamplingAudioContent.annotations',
),
if (c.meta != null)
Expand Down Expand Up @@ -370,7 +379,7 @@ class SamplingTextContent extends SamplingContent {
factory SamplingTextContent.fromJson(Map<String, dynamic> json) =>
SamplingTextContent(
text: json['text'] as String,
annotations: _asJsonObjectOrNull(
annotations: readOptionalAnnotationsObject(
json['annotations'],
'SamplingTextContent.annotations',
),
Expand Down Expand Up @@ -406,7 +415,7 @@ class SamplingImageContent extends SamplingContent {
'SamplingImageContent.data',
),
mimeType: json['mimeType'] as String,
annotations: _asJsonObjectOrNull(
annotations: readOptionalAnnotationsObject(
json['annotations'],
'SamplingImageContent.annotations',
),
Expand Down Expand Up @@ -442,7 +451,7 @@ class SamplingAudioContent extends SamplingContent {
'SamplingAudioContent.data',
),
mimeType: json['mimeType'] as String,
annotations: _asJsonObjectOrNull(
annotations: readOptionalAnnotationsObject(
json['annotations'],
'SamplingAudioContent.annotations',
),
Expand Down
49 changes: 49 additions & 0 deletions lib/src/types/validation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,55 @@ void validateBase64String(String value, String field) {
}
}

List<String>? readOptionalAnnotationAudience(Object? value, String field) {
if (value == null) {
return null;
}
if (value is! List) {
throw FormatException('$field must be a list of roles');
}
return [
for (final item in value)
if (item is String && (item == 'user' || item == 'assistant'))
item
else
throw FormatException('$field items must be "user" or "assistant"'),
];
}

void validateAnnotationAudience(List<String>? value, String field) {
if (value == null) {
return;
}
for (final item in value) {
if (item != 'user' && item != 'assistant') {
throw ArgumentError.value(
item,
field,
'items must be "user" or "assistant"',
);
}
}
}

void validateAnnotationsObject(Map<String, dynamic>? value, String field) {
if (value == null) {
return;
}
readOptionalAnnotationAudience(value['audience'], '$field.audience');
readUnitDouble(value['priority'], '$field.priority');
readOptionalString(value['lastModified'], '$field.lastModified');
}

Map<String, dynamic>? readOptionalAnnotationsObject(
Object? value,
String field,
) {
final result = readOptionalJsonObject(value, field);
validateAnnotationsObject(result, field);
return result;
}

double? readUnitDouble(Object? value, String field) {
final number = readOptionalFiniteNumber(value, field);
final result = number?.toDouble();
Expand Down
10 changes: 10 additions & 0 deletions test/mcp_2025_11_25_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,16 @@ void main() {
() => Annotations.fromJson({'priority': double.infinity}),
throwsA(isA<FormatException>()),
);
expect(
() => Annotations.fromJson({
'audience': ['model'],
}),
throwsA(isA<FormatException>()),
);
expect(
() => Annotations.fromJson({'lastModified': 1}),
throwsA(isA<FormatException>()),
);
expect(
() => CompletionResultData(
values: List.generate(101, (index) => '$index'),
Expand Down
25 changes: 25 additions & 0 deletions test/types/resources_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,31 @@ void main() {
expect(json.containsKey('priority'), isFalse);
expect(json.containsKey('lastModified'), isFalse);
});

test('validates shared annotation fields', () {
expect(
() => ResourceAnnotations.fromJson({
'audience': ['model'],
}),
throwsA(isA<FormatException>()),
);
expect(
() => ResourceAnnotations.fromJson({
'audience': 'user',
}),
throwsA(isA<FormatException>()),
);
expect(
() => ResourceAnnotations.fromJson({
'lastModified': 1,
}),
throwsA(isA<FormatException>()),
);
expect(
() => const ResourceAnnotations(audience: ['model']).toJson(),
throwsA(isA<ArgumentError>()),
);
});
});

group('Resource', () {
Expand Down
43 changes: 41 additions & 2 deletions test/types/sampling_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,41 @@ void main() {
});

test('fromJson parses correctly', () {
final json = {'type': 'text', 'text': 'Parsed text'};
final json = {
'type': 'text',
'text': 'Parsed text',
'annotations': {
'audience': ['user'],
'vendor': {'hint': true},
},
};
final content = SamplingContent.fromJson(json);
expect(content, isA<SamplingTextContent>());
expect((content as SamplingTextContent).text, equals('Parsed text'));
final text = content as SamplingTextContent;
expect(text.text, equals('Parsed text'));
expect(text.annotations?['vendor'], equals({'hint': true}));
});

test('validates shared annotation fields', () {
expect(
() => SamplingContent.fromJson({
'type': 'text',
'text': 'Parsed text',
'annotations': {
'audience': ['model'],
},
}),
throwsA(isA<FormatException>()),
);
expect(
() => const SamplingTextContent(
text: 'Parsed text',
annotations: {
'priority': 2,
},
).toJson(),
throwsA(isA<FormatException>()),
);
});
});

Expand Down Expand Up @@ -139,12 +170,16 @@ void main() {
'type': 'image',
'data': imageData,
'mimeType': 'image/gif',
'annotations': {
'audience': ['assistant'],
},
};
final content = SamplingContent.fromJson(json);
expect(content, isA<SamplingImageContent>());
final img = content as SamplingImageContent;
expect(img.data, equals(imageData));
expect(img.mimeType, equals('image/gif'));
expect(img.annotations?['audience'], equals(['assistant']));
});

test('validates base64 byte data', () {
Expand Down Expand Up @@ -195,12 +230,16 @@ void main() {
'type': 'audio',
'data': audioData,
'mimeType': 'audio/ogg',
'annotations': {
'priority': 0.2,
},
};
final content = SamplingContent.fromJson(json);
expect(content, isA<SamplingAudioContent>());
final audio = content as SamplingAudioContent;
expect(audio.data, equals(audioData));
expect(audio.mimeType, equals('audio/ogg'));
expect(audio.annotations?['priority'], equals(0.2));
});

test('validates base64 byte data', () {
Expand Down
Loading