From 370996f15622b50524714a52d83e73126307f144 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 07:53:43 -0400 Subject: [PATCH] Validate annotation wire fields --- CHANGELOG.md | 2 ++ lib/src/types/content.dart | 28 ++++++++++++++----- lib/src/types/resources.dart | 21 ++++++++++++--- lib/src/types/sampling.dart | 27 ++++++++++++------- lib/src/types/validation.dart | 49 ++++++++++++++++++++++++++++++++++ test/mcp_2025_11_25_test.dart | 10 +++++++ test/types/resources_test.dart | 25 +++++++++++++++++ test/types/sampling_test.dart | 43 +++++++++++++++++++++++++++-- test/types_test.dart | 36 +++++++++++++++++++++++++ 9 files changed, 220 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e364cdf..f2e82694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index f733683a..fd364653 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -56,6 +56,15 @@ String _base64ForJson(String value, String field) { return value; } +Map _annotationsForJson( + Map value, + String field, +) { + final result = readJsonObject(value, field); + validateAnnotationsObject(result, field); + return result; +} + String? _readOptionalPresentString( Map json, String key, @@ -113,12 +122,19 @@ class Annotations { ); factory Annotations.fromJson(Map json) { + final audience = readOptionalAnnotationAudience( + json['audience'], + 'Annotations.audience', + ); return Annotations( - audience: (json['audience'] as List?) - ?.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', + ), ); } @@ -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) @@ -596,7 +612,7 @@ class ResourceLink extends Content { icons: (json['icons'] as List?) ?.map((icon) => McpIcon.fromJson(_asJsonObject(icon))) .toList(), - annotations: _asJsonObjectOrNull( + annotations: readOptionalAnnotationsObject( json['annotations'], 'ResourceLink.annotations', ), diff --git a/lib/src/types/resources.dart b/lib/src/types/resources.dart index 9e9851e0..aa226545 100644 --- a/lib/src/types/resources.dart +++ b/lib/src/types/resources.dart @@ -32,14 +32,24 @@ class ResourceAnnotations { factory ResourceAnnotations.fromJson(Map json) { return ResourceAnnotations( title: json['title'] as String?, - audience: (json['audience'] as List?)?.cast(), + audience: readOptionalAnnotationAudience( + json['audience'], + 'ResourceAnnotations.audience', + ), priority: readUnitDouble(json['priority'], 'ResourceAnnotations.priority'), - lastModified: json['lastModified'] as String?, + lastModified: readOptionalString( + json['lastModified'], + 'ResourceAnnotations.lastModified', + ), ); } Map toJson() { + validateAnnotationAudience( + audience, + 'ResourceAnnotations.audience', + ); validateUnitDouble(priority, 'ResourceAnnotations.priority'); return { if (audience != null) 'audience': audience, @@ -114,7 +124,7 @@ class Resource { size: readOptionalInteger(json['size'], 'Resource.size'), annotations: json['annotations'] != null ? ResourceAnnotations.fromJson( - json['annotations'] as Map, + readJsonObject(json['annotations'], 'Resource.annotations'), ) : null, meta: readOptionalJsonObject(json['_meta'], 'Resource._meta'), @@ -202,7 +212,10 @@ class ResourceTemplate { .toList(), annotations: json['annotations'] != null ? ResourceAnnotations.fromJson( - json['annotations'] as Map, + readJsonObject( + json['annotations'], + 'ResourceTemplate.annotations', + ), ) : null, meta: readOptionalJsonObject(json['_meta'], 'ResourceTemplate._meta'), diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index 116ba087..5b35bdfc 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -30,6 +30,15 @@ String _base64ForJson(String value, String field) { return value; } +Map _annotationsForJson( + Map value, + String field, +) { + final result = readJsonObject(value, field); + validateAnnotationsObject(result, field); + return result; +} + Object _parseSamplingMessageContent(dynamic value) { if (value is List) { return value @@ -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) @@ -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) @@ -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) @@ -370,7 +379,7 @@ class SamplingTextContent extends SamplingContent { factory SamplingTextContent.fromJson(Map json) => SamplingTextContent( text: json['text'] as String, - annotations: _asJsonObjectOrNull( + annotations: readOptionalAnnotationsObject( json['annotations'], 'SamplingTextContent.annotations', ), @@ -406,7 +415,7 @@ class SamplingImageContent extends SamplingContent { 'SamplingImageContent.data', ), mimeType: json['mimeType'] as String, - annotations: _asJsonObjectOrNull( + annotations: readOptionalAnnotationsObject( json['annotations'], 'SamplingImageContent.annotations', ), @@ -442,7 +451,7 @@ class SamplingAudioContent extends SamplingContent { 'SamplingAudioContent.data', ), mimeType: json['mimeType'] as String, - annotations: _asJsonObjectOrNull( + annotations: readOptionalAnnotationsObject( json['annotations'], 'SamplingAudioContent.annotations', ), diff --git a/lib/src/types/validation.dart b/lib/src/types/validation.dart index a3455ab3..86d6d097 100644 --- a/lib/src/types/validation.dart +++ b/lib/src/types/validation.dart @@ -85,6 +85,55 @@ void validateBase64String(String value, String field) { } } +List? 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? 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? value, String field) { + if (value == null) { + return; + } + readOptionalAnnotationAudience(value['audience'], '$field.audience'); + readUnitDouble(value['priority'], '$field.priority'); + readOptionalString(value['lastModified'], '$field.lastModified'); +} + +Map? 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(); diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index db62d1b2..7534de88 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1482,6 +1482,16 @@ void main() { () => Annotations.fromJson({'priority': double.infinity}), throwsA(isA()), ); + expect( + () => Annotations.fromJson({ + 'audience': ['model'], + }), + throwsA(isA()), + ); + expect( + () => Annotations.fromJson({'lastModified': 1}), + throwsA(isA()), + ); expect( () => CompletionResultData( values: List.generate(101, (index) => '$index'), diff --git a/test/types/resources_test.dart b/test/types/resources_test.dart index ed74be1b..3c137c0e 100644 --- a/test/types/resources_test.dart +++ b/test/types/resources_test.dart @@ -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()), + ); + expect( + () => ResourceAnnotations.fromJson({ + 'audience': 'user', + }), + throwsA(isA()), + ); + expect( + () => ResourceAnnotations.fromJson({ + 'lastModified': 1, + }), + throwsA(isA()), + ); + expect( + () => const ResourceAnnotations(audience: ['model']).toJson(), + throwsA(isA()), + ); + }); }); group('Resource', () { diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index ac863c5b..40a8a5d7 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -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()); - 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()), + ); + expect( + () => const SamplingTextContent( + text: 'Parsed text', + annotations: { + 'priority': 2, + }, + ).toJson(), + throwsA(isA()), + ); }); }); @@ -139,12 +170,16 @@ void main() { 'type': 'image', 'data': imageData, 'mimeType': 'image/gif', + 'annotations': { + 'audience': ['assistant'], + }, }; final content = SamplingContent.fromJson(json); expect(content, isA()); 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', () { @@ -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()); 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', () { diff --git a/test/types_test.dart b/test/types_test.dart index a6e304af..120b1f68 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -799,6 +799,7 @@ void main() { annotations: { 'audience': ['assistant'], 'priority': 0.5, + 'vendor': {'hint': true}, }, ); @@ -812,12 +813,47 @@ void main() { expect(deserialized.name, equals('readme')); expect(deserialized.mimeType, equals('text/markdown')); expect(deserialized.annotations?['priority'], equals(0.5)); + expect(deserialized.annotations?['vendor'], equals({'hint': true})); expect( deserialized.parsedAnnotations?.audience, equals([AnnotationAudience.assistant]), ); }); + test('ResourceLink validates shared annotation fields', () { + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + 'annotations': { + 'audience': ['model'], + }, + }), + throwsA(isA()), + ); + expect( + () => const ResourceLink( + uri: 'file:///docs/readme.md', + name: 'readme', + annotations: { + 'priority': 2, + }, + ).toJson(), + throwsA(isA()), + ); + expect( + () => const ResourceLink( + uri: 'file:///docs/readme.md', + name: 'readme', + annotations: { + 'lastModified': 1, + }, + ).toJson(), + throwsA(isA()), + ); + }); + test('Content.fromJson handles resource_link content type', () { final json = { 'type': 'resource_link',