diff --git a/CHANGELOG.md b/CHANGELOG.md index 1722add5..308a9eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,8 @@ - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. +- Rejected non-JSON values in sampling JSON object fields, including + `tool_use.input`, sampling metadata, annotations, and `_meta` maps. ## 2.2.0 diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index 6bc5501f..01580869 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -4,23 +4,23 @@ import 'tasks.dart'; import 'tools.dart'; 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; } @@ -286,26 +286,45 @@ sealed class SamplingContent { ...switch (this) { final SamplingTextContent c => { 'text': c.text, - if (c.annotations != null) 'annotations': c.annotations, - if (c.meta != null) '_meta': c.meta, + if (c.annotations != null) + 'annotations': readJsonObject( + c.annotations, + 'SamplingTextContent.annotations', + ), + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'SamplingTextContent._meta'), }, final SamplingImageContent c => { 'data': c.data, 'mimeType': c.mimeType, - if (c.annotations != null) 'annotations': c.annotations, - if (c.meta != null) '_meta': c.meta, + if (c.annotations != null) + 'annotations': readJsonObject( + c.annotations, + 'SamplingImageContent.annotations', + ), + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'SamplingImageContent._meta'), }, final SamplingAudioContent c => { 'data': c.data, 'mimeType': c.mimeType, - if (c.annotations != null) 'annotations': c.annotations, - if (c.meta != null) '_meta': c.meta, + if (c.annotations != null) + 'annotations': readJsonObject( + c.annotations, + 'SamplingAudioContent.annotations', + ), + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'SamplingAudioContent._meta'), }, final SamplingToolUseContent c => { 'id': c.id, 'name': c.name, - 'input': c.input, - if (c.meta != null) '_meta': c.meta, + 'input': readJsonObject( + c.input, + 'SamplingToolUseContent.input', + ), + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'SamplingToolUseContent._meta'), }, final SamplingToolResultContent c => { 'toolUseId': c.toolUseId, @@ -316,7 +335,11 @@ sealed class SamplingContent { 'SamplingToolResultContent.structuredContent', ), if (c.isError != null) 'isError': c.isError, - if (c.meta != null) '_meta': c.meta, + if (c.meta != null) + '_meta': readJsonObject( + c.meta, + 'SamplingToolResultContent._meta', + ), }, }, }; @@ -342,8 +365,11 @@ class SamplingTextContent extends SamplingContent { factory SamplingTextContent.fromJson(Map json) => SamplingTextContent( text: json['text'] as String, - annotations: _asJsonObjectOrNull(json['annotations']), - meta: _asJsonObjectOrNull(json['_meta']), + annotations: _asJsonObjectOrNull( + json['annotations'], + 'SamplingTextContent.annotations', + ), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingTextContent._meta'), ); } @@ -372,8 +398,11 @@ class SamplingImageContent extends SamplingContent { SamplingImageContent( data: json['data'] as String, mimeType: json['mimeType'] as String, - annotations: _asJsonObjectOrNull(json['annotations']), - meta: _asJsonObjectOrNull(json['_meta']), + annotations: _asJsonObjectOrNull( + json['annotations'], + 'SamplingImageContent.annotations', + ), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingImageContent._meta'), ); } @@ -402,8 +431,11 @@ class SamplingAudioContent extends SamplingContent { SamplingAudioContent( data: json['data'] as String, mimeType: json['mimeType'] as String, - annotations: _asJsonObjectOrNull(json['annotations']), - meta: _asJsonObjectOrNull(json['_meta']), + annotations: _asJsonObjectOrNull( + json['annotations'], + 'SamplingAudioContent.annotations', + ), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingAudioContent._meta'), ); } @@ -425,8 +457,9 @@ class SamplingToolUseContent extends SamplingContent { SamplingToolUseContent( id: json['id'] as String, name: json['name'] as String, - input: _asJsonObject(json['input']), - meta: _asJsonObjectOrNull(json['_meta']), + input: _asJsonObject(json['input'], 'SamplingToolUseContent.input'), + meta: + _asJsonObjectOrNull(json['_meta'], 'SamplingToolUseContent._meta'), ); } @@ -469,7 +502,8 @@ class SamplingToolResultContent extends SamplingContent { : null, hasStructuredContent: json.containsKey('structuredContent'), isError: json['isError'] as bool?, - meta: _asJsonObjectOrNull(json['_meta']), + meta: + _asJsonObjectOrNull(json['_meta'], 'SamplingToolResultContent._meta'), ); } } @@ -508,7 +542,7 @@ class SamplingMessage { return SamplingMessage( role: SamplingMessageRole.values.byName(json['role'] as String), content: _parseSamplingMessageContent(json['content']), - meta: _asJsonObjectOrNull(json['_meta']), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingMessage._meta'), ); } @@ -516,7 +550,8 @@ class SamplingMessage { Map toJson() => { 'role': role.name, 'content': _samplingMessageContentToJson(content), - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'SamplingMessage._meta'), }; } @@ -631,7 +666,10 @@ class CreateMessageRequest { ), maxTokens: json['maxTokens'] as int, stopSequences: (json['stopSequences'] as List?)?.cast(), - metadata: _asJsonObjectOrNull(json['metadata']), + metadata: _asJsonObjectOrNull( + json['metadata'], + 'CreateMessageRequest.metadata', + ), modelPreferences: json['modelPreferences'] == null ? null : ModelPreferences.fromJson( @@ -658,7 +696,11 @@ class CreateMessageRequest { if (temperature != null) 'temperature': temperature, 'maxTokens': maxTokens, if (stopSequences != null) 'stopSequences': stopSequences, - if (metadata != null) 'metadata': metadata, + if (metadata != null) + 'metadata': readJsonObject( + metadata, + 'CreateMessageRequest.metadata', + ), if (modelPreferences != null) 'modelPreferences': modelPreferences!.toJson(), if (tools != null) 'tools': tools!.map((t) => t.toJson()).toList(), @@ -738,7 +780,8 @@ class CreateMessageResult implements BaseResultData { } factory CreateMessageResult.fromJson(Map json) { - final meta = _asJsonObjectOrNull(json['_meta']); + final meta = + _asJsonObjectOrNull(json['_meta'], 'CreateMessageResult._meta'); dynamic reason = json['stopReason']; if (reason is String) { try { @@ -774,7 +817,8 @@ class CreateMessageResult implements BaseResultData { 'stopReason': reason is StopReason ? reason.name : reason, 'role': role.name, 'content': _samplingMessageContentToJson(content), - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'CreateMessageResult._meta'), }; } } diff --git a/lib/src/types/validation.dart b/lib/src/types/validation.dart index 2ab39784..21220b97 100644 --- a/lib/src/types/validation.dart +++ b/lib/src/types/validation.dart @@ -148,3 +148,17 @@ Object? readJsonValue(Object? value, String field) { } throw FormatException('$field must be a JSON value'); } + +Map readJsonObject(Object? value, String field) { + if (value is! Map) { + throw FormatException('$field must be a JSON object'); + } + return (readJsonValue(value, field) as Map).cast(); +} + +Map? readOptionalJsonObject(Object? value, String field) { + if (value == null) { + return null; + } + return readJsonObject(value, field); +} diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index bf706967..e8032bbd 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -518,6 +518,48 @@ void main() { expect((json['content'] as List).single['type'], 'tool_use'); }); + test('Sampling JSON object fields reject non-JSON Dart maps', () { + expect( + () => SamplingToolUseContent.fromJson({ + 'type': 'tool_use', + 'id': 'call-1', + 'name': 'calculator', + 'input': {'expr': Object()}, + }), + throwsA(isA()), + ); + expect( + () => SamplingMessage.fromJson({ + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + '_meta': {'provider': Object()}, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageResult.fromJson({ + 'role': 'assistant', + 'content': {'type': 'text', 'text': 'Hello'}, + 'model': 'model-x', + '_meta': {'provider': Object()}, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'metadata': {'provider': 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 0f151f75..91942426 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -367,6 +367,48 @@ void main() { ); }); + test('rejects non-JSON sampling object values', () { + expect( + () => SamplingToolUseContent.fromJson({ + 'type': 'tool_use', + 'id': 'call-1', + 'name': 'lookup', + 'input': {'query': Object()}, + }), + throwsA(isA()), + ); + expect( + () => SamplingMessage.fromJson({ + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + '_meta': {'provider': Object()}, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageResult.fromJson({ + 'role': 'assistant', + 'content': {'type': 'text', 'text': 'Hello'}, + 'model': 'model-x', + '_meta': {'provider': Object()}, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequest.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 16, + 'metadata': {'provider': Object()}, + }), + throwsA(isA()), + ); + }); + test('serializes server/discover request and result', () { final request = JsonRpcServerDiscoverRequest( id: 'discover-1', diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index 10eeef83..8b5e558b 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -1,5 +1,6 @@ import 'package:mcp_dart/src/types/content.dart'; import 'package:mcp_dart/src/types/sampling.dart'; +import 'package:mcp_dart/src/types/validation.dart'; import 'package:test/test.dart'; void main() { @@ -112,6 +113,34 @@ void main() { expect(content, isA()); expect((content as SamplingTextContent).text, equals('Parsed text')); }); + + test('preserves annotations and metadata JSON objects', () { + const content = SamplingTextContent( + text: 'With metadata', + annotations: { + 'audience': ['user'], + }, + meta: {'trace': true}, + ); + + final json = content.toJson(); + expect( + json['annotations'], + equals({ + 'audience': ['user'], + }), + ); + expect(json['_meta'], equals({'trace': true})); + + final parsed = SamplingContent.fromJson(json) as SamplingTextContent; + expect( + parsed.annotations, + equals({ + 'audience': ['user'], + }), + ); + expect(parsed.meta, equals({'trace': true})); + }); }); group('SamplingImageContent', () { @@ -143,6 +172,23 @@ void main() { expect(img.data, equals('encoded')); expect(img.mimeType, equals('image/gif')); }); + + test('preserves annotations and metadata JSON objects', () { + const content = SamplingImageContent( + data: 'imgdata', + mimeType: 'image/png', + annotations: {'priority': 1}, + meta: {'source': 'camera'}, + ); + + final json = content.toJson(); + expect(json['annotations'], equals({'priority': 1})); + expect(json['_meta'], equals({'source': 'camera'})); + + final parsed = SamplingContent.fromJson(json) as SamplingImageContent; + expect(parsed.annotations, equals({'priority': 1})); + expect(parsed.meta, equals({'source': 'camera'})); + }); }); group('SamplingAudioContent', () { @@ -178,6 +224,23 @@ void main() { expect(audio.data, equals('encoded-audio')); expect(audio.mimeType, equals('audio/ogg')); }); + + test('preserves annotations and metadata JSON objects', () { + const content = SamplingAudioContent( + data: 'audio-data', + mimeType: 'audio/wav', + annotations: {'language': 'en'}, + meta: {'channel': 'left'}, + ); + + final json = content.toJson(); + expect(json['annotations'], equals({'language': 'en'})); + expect(json['_meta'], equals({'channel': 'left'})); + + final parsed = SamplingContent.fromJson(json) as SamplingAudioContent; + expect(parsed.annotations, equals({'language': 'en'})); + expect(parsed.meta, equals({'channel': 'left'})); + }); }); group('SamplingToolUseContent', () { @@ -217,6 +280,50 @@ void main() { expect(tool.name, equals('fetch')); expect(tool.id, equals('tu1')); }); + + test('preserves metadata JSON objects', () { + const content = SamplingToolUseContent( + id: 'tu1', + name: 'fetch', + input: {'url': 'http://test.com'}, + meta: {'origin': 'model'}, + ); + + final json = content.toJson(); + expect(json['_meta'], equals({'origin': 'model'})); + + final parsed = SamplingContent.fromJson(json) as SamplingToolUseContent; + expect(parsed.meta, equals({'origin': 'model'})); + }); + + test('rejects non-JSON input objects', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'tool_use', + 'id': 'tu1', + 'name': 'fetch', + 'input': 'not an object', + }), + throwsA(isA()), + ); + expect( + () => SamplingContent.fromJson({ + 'type': 'tool_use', + 'id': 'tu1', + 'name': 'fetch', + 'input': {1: 'bad'}, + }), + throwsA(isA()), + ); + expect( + () => const SamplingToolUseContent( + id: 'tu1', + name: 'fetch', + input: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + }); }); group('SamplingToolResultContent', () { @@ -320,6 +427,23 @@ void main() { expect(nullContent.hasStructuredContent, isTrue); expect(nullContent.structuredContent, isNull); }); + + test('preserves metadata JSON objects', () { + const content = SamplingToolResultContent( + toolUseId: 'tr1', + content: [ + TextContent(text: 'result data'), + ], + meta: {'origin': 'tool'}, + ); + + final json = content.toJson(); + expect(json['_meta'], equals({'origin': 'tool'})); + + final parsed = + SamplingContent.fromJson(json) as SamplingToolResultContent; + expect(parsed.meta, equals({'origin': 'tool'})); + }); }); }); @@ -348,10 +472,12 @@ void main() { final json = { 'role': 'user', 'content': {'type': 'text', 'text': 'Question'}, + '_meta': {'requestId': 'abc'}, }; final msg = SamplingMessage.fromJson(json); expect(msg.role, equals(SamplingMessageRole.user)); expect(msg.content, isA()); + expect(msg.meta, equals({'requestId': 'abc'})); }); test('supports array content with normalized contentBlocks', () { @@ -367,6 +493,25 @@ void main() { expect(msg.contentBlocks, hasLength(2)); expect(msg.toJson()['content'], isA()); }); + + test('rejects non-JSON metadata objects', () { + expect( + () => SamplingMessage.fromJson({ + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + '_meta': {1: 'bad'}, + }), + throwsA(isA()), + ); + expect( + () => const SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Hello'), + meta: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + }); }); group('CreateMessageRequestParams', () { @@ -394,6 +539,7 @@ void main() { ], maxTokens: 500, includeContext: IncludeContext.thisServer, + metadata: {'provider': 'test'}, modelPreferences: ModelPreferences(costPriority: 0.5), stopSequences: ['STOP'], temperature: 0.7, @@ -401,6 +547,7 @@ void main() { final json = params.toJson(); expect(json['maxTokens'], equals(500)); expect(json['includeContext'], equals('thisServer')); + expect(json['metadata'], equals({'provider': 'test'})); expect(json['stopSequences'], contains('STOP')); expect(json['temperature'], equals(0.7)); }); @@ -448,11 +595,13 @@ void main() { ], 'maxTokens': 200, 'includeContext': 'allServers', + 'metadata': {'provider': 'test'}, }; final params = CreateMessageRequestParams.fromJson(json); expect(params.messages, hasLength(1)); expect(params.maxTokens, equals(200)); expect(params.includeContext, equals(IncludeContext.allServers)); + expect(params.metadata, equals({'provider': 'test'})); }); test('rejects non-finite temperature values', () { @@ -486,6 +635,36 @@ void main() { ); } }); + + test('rejects non-JSON metadata objects', () { + final messages = [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ]; + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'metadata': {1: 'bad'}, + }), + throwsA(isA()), + ); + expect( + () => const CreateMessageRequestParams( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Hello'), + ), + ], + maxTokens: 100, + metadata: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + }); }); group('CreateMessageResult', () { @@ -533,11 +712,13 @@ void main() { 'content': {'type': 'text', 'text': 'Message'}, 'model': 'gemini', 'stopReason': 'stopSequence', + '_meta': {'trace': true}, }; final result = CreateMessageResult.fromJson(json); expect(result.role, equals(SamplingMessageRole.assistant)); expect(result.model, equals('gemini')); expect(result.stopReason, equals(StopReason.stopSequence)); + expect(result.meta, equals({'trace': true})); }); test('handles string stopReason', () { @@ -550,6 +731,27 @@ void main() { final result = CreateMessageResult.fromJson(json); expect(result.stopReason, equals('customReason')); }); + + test('rejects non-JSON metadata objects', () { + expect( + () => CreateMessageResult.fromJson({ + 'role': 'assistant', + 'content': {'type': 'text', 'text': 'Message'}, + 'model': 'model-x', + '_meta': {1: 'bad'}, + }), + throwsA(isA()), + ); + expect( + () => const CreateMessageResult( + role: SamplingMessageRole.assistant, + content: SamplingTextContent(text: 'Message'), + model: 'model-x', + meta: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + }); }); group('JsonRpcCreateMessageRequest', () { @@ -629,4 +831,28 @@ void main() { expect(SamplingMessageRole.assistant.name, equals('assistant')); }); }); + + group('JSON object validation helpers', () { + test('accept optional JSON object values', () { + expect(readOptionalJsonObject(null, 'field'), isNull); + expect( + readOptionalJsonObject( + { + 'nested': ['value'], + }, + 'field', + ), + equals({ + 'nested': ['value'], + }), + ); + }); + + test('reject non-object JSON object values', () { + expect( + () => readJsonObject('bad', 'field'), + throwsA(isA()), + ); + }); + }); }