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 @@ -113,6 +113,8 @@
instead of silently treating them as successful responses.
- Rejected JSON-RPC request and notification envelopes whose `method` member is
not a string, and validated generic request `params` as JSON objects.
- Rejected malformed JSON-RPC `error` objects with missing or invalid `code` or
`message` fields instead of surfacing Dart cast errors.
- Prevented stateless MCP 2026 clients from sending core request and
notification methods removed from that protocol revision.
- Rejected server-initiated JSON-RPC requests received by stateless MCP 2026
Expand Down
24 changes: 21 additions & 3 deletions lib/src/types/json_rpc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,22 @@ String _parseMethod(Object? value) {
);
}

int _parseErrorCode(Object? value) {
final code = readOptionalInteger(value, 'JsonRpcErrorData.code');
if (code == null) {
throw const FormatException('JsonRpcErrorData.code is required');
}
return code;
}

String _parseErrorMessage(Object? value) {
final message = readOptionalString(value, 'JsonRpcErrorData.message');
if (message == null) {
throw const FormatException('JsonRpcErrorData.message is required');
}
return message;
}

RequestId _parseResultResponseId(Object? value) {
return parseRequestId(value);
}
Expand Down Expand Up @@ -600,8 +616,8 @@ class JsonRpcErrorData {

factory JsonRpcErrorData.fromJson(Map<String, dynamic> json) =>
JsonRpcErrorData(
code: json['code'] as int,
message: json['message'] as String,
code: _parseErrorCode(json['code']),
message: _parseErrorMessage(json['message']),
data: json.containsKey('data')
? readJsonValue(json['data'], 'JsonRpcErrorData.data')
: null,
Expand All @@ -623,7 +639,9 @@ class JsonRpcError extends JsonRpcMessage {

factory JsonRpcError.fromJson(Map<String, dynamic> json) => JsonRpcError(
id: _parseErrorResponseId(json),
error: JsonRpcErrorData.fromJson(json['error'] as Map<String, dynamic>),
error: JsonRpcErrorData.fromJson(
readJsonObject(json['error'], 'JsonRpcError.error'),
),
);

@override
Expand Down
20 changes: 20 additions & 0 deletions packages/mcp_dart_cli/lib/src/conformance_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ class ConformanceRunner {
'Rejects JSON-RPC responses that include both result and error members.',
check: _rejectsResultErrorJsonRpcResponse,
),
_ConformanceCase(
suite: _fixtureSuite,
name: 'jsonrpc.rejects-malformed-error-object',
description:
'Rejects JSON-RPC error responses whose error member is malformed.',
check: _rejectsMalformedJsonRpcErrorObject,
),
_ConformanceCase(
suite: _fixtureSuite,
name: 'jsonrpc.preserves-string-response-id',
Expand Down Expand Up @@ -763,6 +770,19 @@ Future<void> _rejectsResultErrorJsonRpcResponse() async {
);
}

Future<void> _rejectsMalformedJsonRpcErrorObject() async {
_expectThrowsFormatException(
() => JsonRpcMessage.fromJson(const <String, dynamic>{
'jsonrpc': jsonRpcVersion,
'id': 1,
'error': <String, dynamic>{
'code': 'not-a-number',
'message': 'Invalid request',
},
}),
);
}

Future<void> _preservesStringResponseId() async {
final message = JsonRpcMessage.fromJson(const <String, dynamic>{
'jsonrpc': jsonRpcVersion,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ void main() {
'jsonrpc.rejects-malformed-message',
'jsonrpc.rejects-non-string-method',
'jsonrpc.rejects-result-error-response',
'jsonrpc.rejects-malformed-error-object',
'jsonrpc.preserves-string-response-id',
'jsonrpc.preserves-numeric-response-id',
'jsonrpc.preserves-string-progress-token',
Expand Down
60 changes: 60 additions & 0 deletions test/types_edge_cases_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,50 @@ void main() {
expect(restored.data['nested']['level'], equals(2));
});

test('JsonRpcErrorData validates required code and message fields', () {
for (final code in [
null,
false,
'not-code',
1.5,
<String, dynamic>{},
<Object>[],
]) {
expect(
() => JsonRpcErrorData.fromJson({
'code': code,
'message': 'Bad code',
}),
throwsA(
isA<FormatException>()
.having((e) => e.message, 'message', contains('code')),
),
);
}

for (final message in [null, false, 1, <String, dynamic>{}, <Object>[]]) {
expect(
() => JsonRpcErrorData.fromJson({
'code': ErrorCode.invalidRequest.value,
'message': message,
}),
throwsA(
isA<FormatException>()
.having((e) => e.message, 'message', contains('message')),
),
);
}
});

test('JsonRpcErrorData accepts whole-number numeric code values', () {
final errorData = JsonRpcErrorData.fromJson({
'code': -32600.0,
'message': 'Whole-number JSON code',
});

expect(errorData.code, ErrorCode.invalidRequest.value);
});

test('JsonRpcErrorData rejects non-JSON data values', () {
expect(
() => const JsonRpcErrorData(
Expand All @@ -80,6 +124,22 @@ void main() {
throwsA(isA<FormatException>()),
);
});

test('JsonRpcError rejects malformed error object wire values', () {
for (final error in [null, false, 1, 'not-error', <Object>[]]) {
expect(
() => JsonRpcMessage.fromJson({
'jsonrpc': '2.0',
'id': 1,
'error': error,
}),
throwsA(
isA<FormatException>()
.having((e) => e.message, 'message', contains('error')),
),
);
}
});
});

group('JsonRpcCancelledNotification Edge Cases', () {
Expand Down