From 43b3f9f21e70b3b60a0e4d5f88e848889ea0e64b Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sun, 31 May 2026 22:04:37 -0400 Subject: [PATCH] Validate content JSON object fields --- CHANGELOG.md | 2 + lib/src/types/content.dart | 95 ++++++++++++++++++++++------------- test/mcp_2025_11_25_test.dart | 28 +++++++++++ test/mcp_2026_07_28_test.dart | 28 +++++++++++ test/types_test.dart | 70 ++++++++++++++++++++++++++ 5 files changed, 188 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 308a9eeb..4ce46a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,8 @@ JSON numbers. - Rejected non-JSON values in sampling JSON object fields, including `tool_use.input`, sampling metadata, annotations, and `_meta` maps. +- Rejected non-JSON values in common content/resource metadata fields and + `resource_link.annotations`. ## 2.2.0 diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index 87845180..42c612b5 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -1,25 +1,22 @@ import 'validation.dart'; -Map? _asJsonObjectOrNull(dynamic value) { +Map? _asJsonObjectOrNull( + dynamic value, [ + String field = 'object', +]) { if (value == null) { return null; } - - if (value is Map) { - return value; - } - - if (value is Map) { - return value.cast(); - } - - throw FormatException('Expected object, got ${value.runtimeType}'); + return readJsonObject(value, field); } -Map _asJsonObject(dynamic value) { - final map = _asJsonObjectOrNull(value); +Map _asJsonObject( + dynamic value, [ + String field = 'object', +]) { + final map = _asJsonObjectOrNull(value, field); if (map == null) { - throw const FormatException('Expected object, got null'); + throw FormatException('$field must be a JSON object'); } return map; } @@ -93,7 +90,10 @@ sealed class ResourceContents { factory ResourceContents.fromJson(Map json) { final uri = json['uri'] as String; final mimeType = json['mimeType'] as String?; - final meta = _asJsonObjectOrNull(json['_meta']); + final meta = _asJsonObjectOrNull( + json['_meta'], + 'ResourceContents._meta', + ); final extra = Map.from(json) ..removeWhere( (key, value) => @@ -104,7 +104,8 @@ sealed class ResourceContents { key == '_meta', ); - final passthrough = extra.isEmpty ? null : extra; + final passthrough = + extra.isEmpty ? null : readJsonObject(extra, 'ResourceContents.extra'); if (json.containsKey('text')) { return TextResourceContents( @@ -141,8 +142,9 @@ sealed class ResourceContents { final BlobResourceContents c => {'blob': c.blob}, UnknownResourceContents _ => {}, }, - if (meta != null) '_meta': meta, - ...?extra, + if (meta != null) + '_meta': readJsonObject(meta, 'ResourceContents._meta'), + if (extra != null) ...readJsonObject(extra, 'ResourceContents.extra'), }; } @@ -259,20 +261,23 @@ sealed class Content { final TextContent c => { 'text': c.text, if (c.annotations != null) 'annotations': c.annotations!.toJson(), - if (c.meta != null) '_meta': c.meta, + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'TextContent._meta'), }, final ImageContent c => { 'data': c.data, 'mimeType': c.mimeType, if (c.theme != null) 'theme': c.theme, if (c.annotations != null) 'annotations': c.annotations!.toJson(), - if (c.meta != null) '_meta': c.meta, + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'ImageContent._meta'), }, final AudioContent c => { 'data': c.data, 'mimeType': c.mimeType, if (c.annotations != null) 'annotations': c.annotations!.toJson(), - if (c.meta != null) '_meta': c.meta, + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'AudioContent._meta'), }, final ResourceLink c => { 'uri': c.uri, @@ -283,13 +288,19 @@ sealed class Content { if (c.size != null) 'size': c.size, if (c.icons != null) 'icons': c.icons!.map((icon) => icon.toJson()).toList(), - if (c.annotations != null) 'annotations': c.annotations, - if (c.meta != null) '_meta': c.meta, + if (c.annotations != null) + 'annotations': readJsonObject( + c.annotations, + 'ResourceLink.annotations', + ), + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'ResourceLink._meta'), }, final EmbeddedResource c => { 'resource': c.resource.toJson(), if (c.annotations != null) 'annotations': c.annotations!.toJson(), - if (c.meta != null) '_meta': c.meta, + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'EmbeddedResource._meta'), }, UnknownContent _ => {}, }, @@ -318,8 +329,10 @@ class TextContent extends Content { text: json['text'] as String, annotations: json['annotations'] == null ? null - : Annotations.fromJson(_asJsonObject(json['annotations'])), - meta: _asJsonObjectOrNull(json['_meta']), + : Annotations.fromJson( + _asJsonObject(json['annotations'], 'TextContent.annotations'), + ), + meta: _asJsonObjectOrNull(json['_meta'], 'TextContent._meta'), ); } } @@ -356,8 +369,10 @@ class ImageContent extends Content { theme: json['theme'] as String?, annotations: json['annotations'] == null ? null - : Annotations.fromJson(_asJsonObject(json['annotations'])), - meta: _asJsonObjectOrNull(json['_meta']), + : Annotations.fromJson( + _asJsonObject(json['annotations'], 'ImageContent.annotations'), + ), + meta: _asJsonObjectOrNull(json['_meta'], 'ImageContent._meta'), ); } } @@ -388,8 +403,10 @@ class AudioContent extends Content { mimeType: json['mimeType'] as String, annotations: json['annotations'] == null ? null - : Annotations.fromJson(_asJsonObject(json['annotations'])), - meta: _asJsonObjectOrNull(json['_meta']), + : Annotations.fromJson( + _asJsonObject(json['annotations'], 'AudioContent.annotations'), + ), + meta: _asJsonObjectOrNull(json['_meta'], 'AudioContent._meta'), ); } } @@ -414,12 +431,17 @@ class EmbeddedResource extends Content { factory EmbeddedResource.fromJson(Map json) { return EmbeddedResource( resource: ResourceContents.fromJson( - _asJsonObject(json['resource']), + _asJsonObject(json['resource'], 'EmbeddedResource.resource'), ), annotations: json['annotations'] == null ? null - : Annotations.fromJson(_asJsonObject(json['annotations'])), - meta: _asJsonObjectOrNull(json['_meta']), + : Annotations.fromJson( + _asJsonObject( + json['annotations'], + 'EmbeddedResource.annotations', + ), + ), + meta: _asJsonObjectOrNull(json['_meta'], 'EmbeddedResource._meta'), ); } } @@ -480,8 +502,11 @@ class ResourceLink extends Content { icons: (json['icons'] as List?) ?.map((icon) => McpIcon.fromJson(_asJsonObject(icon))) .toList(), - annotations: _asJsonObjectOrNull(json['annotations']), - meta: _asJsonObjectOrNull(json['_meta']), + annotations: _asJsonObjectOrNull( + json['annotations'], + 'ResourceLink.annotations', + ), + meta: _asJsonObjectOrNull(json['_meta'], 'ResourceLink._meta'), ); } } diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index e8032bbd..6763b85e 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -560,6 +560,34 @@ void main() { ); }); + test('Content JSON object fields reject non-JSON Dart maps', () { + expect( + () => TextContent.fromJson({ + 'type': 'text', + 'text': 'Hello', + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + 'text': 'README body', + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + 'annotations': {'bad': Object()}, + }), + throwsA(isA()), + ); + }); + group('Tasks API Types', () { test('GetTaskRequestParams serialization', () { final params = const GetTaskRequestParams(taskId: 'task-123'); diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 91942426..e59a42f9 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -409,6 +409,34 @@ void main() { ); }); + test('rejects non-JSON content object values', () { + expect( + () => TextContent.fromJson({ + 'type': 'text', + 'text': 'Hello', + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + 'text': 'README body', + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + 'annotations': {'bad': Object()}, + }), + throwsA(isA()), + ); + }); + test('serializes server/discover request and result', () { final request = JsonRpcServerDiscoverRequest( id: 'discover-1', diff --git a/test/types_test.dart b/test/types_test.dart index 48cf416e..16444fe8 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -474,6 +474,41 @@ void main() { ); expect(deserialized.meta?['display'], equals('inline')); }); + + test('content JSON object fields reject non-JSON Dart maps', () { + expect( + () => TextContent.fromJson({ + 'type': 'text', + 'text': 'Hello', + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => const TextContent( + text: 'Hello', + meta: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + 'annotations': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => const ResourceLink( + uri: 'file:///docs/readme.md', + name: 'readme', + annotations: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + }); }); group('Resource Tests', () { @@ -531,6 +566,41 @@ void main() { expect(deserialized.uri, equals('file://example.bin')); expect(deserialized.blob, equals('base64data')); }); + + test('ResourceContents rejects non-JSON metadata and passthrough maps', () { + expect( + () => ResourceContents.fromJson({ + 'uri': 'file://example.txt', + 'text': 'Sample text content', + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file://example.txt', + 'text': 'Sample text content', + 'x-extra': Object(), + }), + throwsA(isA()), + ); + expect( + () => const TextResourceContents( + uri: 'file://example.txt', + text: 'Sample text content', + meta: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + expect( + () => const TextResourceContents( + uri: 'file://example.txt', + text: 'Sample text content', + extra: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + }); }); group('Prompt Tests', () {