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 @@ -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

Expand Down
95 changes: 60 additions & 35 deletions lib/src/types/content.dart
Original file line number Diff line number Diff line change
@@ -1,25 +1,22 @@
import 'validation.dart';

Map<String, dynamic>? _asJsonObjectOrNull(dynamic value) {
Map<String, dynamic>? _asJsonObjectOrNull(
dynamic value, [
String field = 'object',
]) {
if (value == null) {
return null;
}

if (value is Map<String, dynamic>) {
return value;
}

if (value is Map) {
return value.cast<String, dynamic>();
}

throw FormatException('Expected object, got ${value.runtimeType}');
return readJsonObject(value, field);
}

Map<String, dynamic> _asJsonObject(dynamic value) {
final map = _asJsonObjectOrNull(value);
Map<String, dynamic> _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;
}
Expand Down Expand Up @@ -93,7 +90,10 @@ sealed class ResourceContents {
factory ResourceContents.fromJson(Map<String, dynamic> 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<String, dynamic>.from(json)
..removeWhere(
(key, value) =>
Expand All @@ -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(
Expand Down Expand Up @@ -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'),
};
}

Expand Down Expand Up @@ -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,
Expand All @@ -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 _ => {},
},
Expand Down Expand Up @@ -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'),
);
}
}
Expand Down Expand Up @@ -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'),
);
}
}
Expand Down Expand Up @@ -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'),
);
}
}
Expand All @@ -414,12 +431,17 @@ class EmbeddedResource extends Content {
factory EmbeddedResource.fromJson(Map<String, dynamic> 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'),
);
}
}
Expand Down Expand Up @@ -480,8 +502,11 @@ class ResourceLink extends Content {
icons: (json['icons'] as List<dynamic>?)
?.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'),
);
}
}
Expand Down
28 changes: 28 additions & 0 deletions test/mcp_2025_11_25_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormatException>()),
);
expect(
() => ResourceContents.fromJson({
'uri': 'file:///docs/readme.md',
'text': 'README body',
'_meta': {'bad': Object()},
}),
throwsA(isA<FormatException>()),
);
expect(
() => ResourceLink.fromJson({
'type': 'resource_link',
'uri': 'file:///docs/readme.md',
'name': 'readme',
'annotations': {'bad': Object()},
}),
throwsA(isA<FormatException>()),
);
});

group('Tasks API Types', () {
test('GetTaskRequestParams serialization', () {
final params = const GetTaskRequestParams(taskId: 'task-123');
Expand Down
28 changes: 28 additions & 0 deletions test/mcp_2026_07_28_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormatException>()),
);
expect(
() => ResourceContents.fromJson({
'uri': 'file:///docs/readme.md',
'text': 'README body',
'_meta': {'bad': Object()},
}),
throwsA(isA<FormatException>()),
);
expect(
() => ResourceLink.fromJson({
'type': 'resource_link',
'uri': 'file:///docs/readme.md',
'name': 'readme',
'annotations': {'bad': Object()},
}),
throwsA(isA<FormatException>()),
);
});

test('serializes server/discover request and result', () {
final request = JsonRpcServerDiscoverRequest(
id: 'discover-1',
Expand Down
70 changes: 70 additions & 0 deletions test/types_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormatException>()),
);
expect(
() => const TextContent(
text: 'Hello',
meta: {'bad': Object()},
).toJson(),
throwsA(isA<FormatException>()),
);
expect(
() => ResourceLink.fromJson({
'type': 'resource_link',
'uri': 'file:///docs/readme.md',
'name': 'readme',
'annotations': {'bad': Object()},
}),
throwsA(isA<FormatException>()),
);
expect(
() => const ResourceLink(
uri: 'file:///docs/readme.md',
name: 'readme',
annotations: {'bad': Object()},
).toJson(),
throwsA(isA<FormatException>()),
);
});
});

group('Resource Tests', () {
Expand Down Expand Up @@ -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<FormatException>()),
);
expect(
() => ResourceContents.fromJson({
'uri': 'file://example.txt',
'text': 'Sample text content',
'x-extra': Object(),
}),
throwsA(isA<FormatException>()),
);
expect(
() => const TextResourceContents(
uri: 'file://example.txt',
text: 'Sample text content',
meta: {'bad': Object()},
).toJson(),
throwsA(isA<FormatException>()),
);
expect(
() => const TextResourceContents(
uri: 'file://example.txt',
text: 'Sample text content',
extra: {'bad': Object()},
).toJson(),
throwsA(isA<FormatException>()),
);
});
});

group('Prompt Tests', () {
Expand Down