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

Expand Down
114 changes: 79 additions & 35 deletions lib/src/types/sampling.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,23 @@ import 'tasks.dart';
import 'tools.dart';
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 @@ -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,
Expand All @@ -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',
),
},
},
};
Expand All @@ -342,8 +365,11 @@ class SamplingTextContent extends SamplingContent {
factory SamplingTextContent.fromJson(Map<String, dynamic> 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'),
);
}

Expand Down Expand Up @@ -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'),
);
}

Expand Down Expand Up @@ -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'),
);
}

Expand All @@ -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'),
);
}

Expand Down Expand Up @@ -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'),
);
}
}
Expand Down Expand Up @@ -508,15 +542,16 @@ 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'),
);
}

/// Converts to JSON.
Map<String, dynamic> toJson() => {
'role': role.name,
'content': _samplingMessageContentToJson(content),
if (meta != null) '_meta': meta,
if (meta != null)
'_meta': readJsonObject(meta, 'SamplingMessage._meta'),
};
}

Expand Down Expand Up @@ -631,7 +666,10 @@ class CreateMessageRequest {
),
maxTokens: json['maxTokens'] as int,
stopSequences: (json['stopSequences'] as List<dynamic>?)?.cast<String>(),
metadata: _asJsonObjectOrNull(json['metadata']),
metadata: _asJsonObjectOrNull(
json['metadata'],
'CreateMessageRequest.metadata',
),
modelPreferences: json['modelPreferences'] == null
? null
: ModelPreferences.fromJson(
Expand All @@ -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(),
Expand Down Expand Up @@ -738,7 +780,8 @@ class CreateMessageResult implements BaseResultData {
}

factory CreateMessageResult.fromJson(Map<String, dynamic> json) {
final meta = _asJsonObjectOrNull(json['_meta']);
final meta =
_asJsonObjectOrNull(json['_meta'], 'CreateMessageResult._meta');
dynamic reason = json['stopReason'];
if (reason is String) {
try {
Expand Down Expand Up @@ -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'),
};
}
}
Expand Down
14 changes: 14 additions & 0 deletions lib/src/types/validation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,17 @@ Object? readJsonValue(Object? value, String field) {
}
throw FormatException('$field must be a JSON value');
}

Map<String, dynamic> readJsonObject(Object? value, String field) {
if (value is! Map) {
throw FormatException('$field must be a JSON object');
}
return (readJsonValue(value, field) as Map).cast<String, dynamic>();
}

Map<String, dynamic>? readOptionalJsonObject(Object? value, String field) {
if (value == null) {
return null;
}
return readJsonObject(value, field);
}
42 changes: 42 additions & 0 deletions test/mcp_2025_11_25_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormatException>()),
);
expect(
() => SamplingMessage.fromJson({
'role': 'user',
'content': {'type': 'text', 'text': 'Hello'},
'_meta': {'provider': Object()},
}),
throwsA(isA<FormatException>()),
);
expect(
() => CreateMessageResult.fromJson({
'role': 'assistant',
'content': {'type': 'text', 'text': 'Hello'},
'model': 'model-x',
'_meta': {'provider': Object()},
}),
throwsA(isA<FormatException>()),
);
expect(
() => CreateMessageRequestParams.fromJson({
'messages': [
{
'role': 'user',
'content': {'type': 'text', 'text': 'Hello'},
},
],
'maxTokens': 100,
'metadata': {'provider': Object()},
}),
throwsA(isA<FormatException>()),
);
});

group('Tasks API Types', () {
test('GetTaskRequestParams serialization', () {
final params = const GetTaskRequestParams(taskId: 'task-123');
Expand Down
42 changes: 42 additions & 0 deletions test/mcp_2026_07_28_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormatException>()),
);
expect(
() => SamplingMessage.fromJson({
'role': 'user',
'content': {'type': 'text', 'text': 'Hello'},
'_meta': {'provider': Object()},
}),
throwsA(isA<FormatException>()),
);
expect(
() => CreateMessageResult.fromJson({
'role': 'assistant',
'content': {'type': 'text', 'text': 'Hello'},
'model': 'model-x',
'_meta': {'provider': Object()},
}),
throwsA(isA<FormatException>()),
);
expect(
() => CreateMessageRequest.fromJson({
'messages': [
{
'role': 'user',
'content': {'type': 'text', 'text': 'Hello'},
},
],
'maxTokens': 16,
'metadata': {'provider': Object()},
}),
throwsA(isA<FormatException>()),
);
});

test('serializes server/discover request and result', () {
final request = JsonRpcServerDiscoverRequest(
id: 'discover-1',
Expand Down
Loading