diff --git a/CHANGELOG.md b/CHANGELOG.md index 0178290c..f16928f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 67ee497a..89c12aac 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -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); } @@ -600,8 +616,8 @@ class JsonRpcErrorData { factory JsonRpcErrorData.fromJson(Map 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, @@ -623,7 +639,9 @@ class JsonRpcError extends JsonRpcMessage { factory JsonRpcError.fromJson(Map json) => JsonRpcError( id: _parseErrorResponseId(json), - error: JsonRpcErrorData.fromJson(json['error'] as Map), + error: JsonRpcErrorData.fromJson( + readJsonObject(json['error'], 'JsonRpcError.error'), + ), ); @override diff --git a/packages/mcp_dart_cli/lib/src/conformance_runner.dart b/packages/mcp_dart_cli/lib/src/conformance_runner.dart index 5154ddf8..5c6968d4 100644 --- a/packages/mcp_dart_cli/lib/src/conformance_runner.dart +++ b/packages/mcp_dart_cli/lib/src/conformance_runner.dart @@ -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', @@ -763,6 +770,19 @@ Future _rejectsResultErrorJsonRpcResponse() async { ); } +Future _rejectsMalformedJsonRpcErrorObject() async { + _expectThrowsFormatException( + () => JsonRpcMessage.fromJson(const { + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'error': { + 'code': 'not-a-number', + 'message': 'Invalid request', + }, + }), + ); +} + Future _preservesStringResponseId() async { final message = JsonRpcMessage.fromJson(const { 'jsonrpc': jsonRpcVersion, diff --git a/packages/mcp_dart_cli/test/src/conformance_command_test.dart b/packages/mcp_dart_cli/test/src/conformance_command_test.dart index 3c363215..a6b7870e 100644 --- a/packages/mcp_dart_cli/test/src/conformance_command_test.dart +++ b/packages/mcp_dart_cli/test/src/conformance_command_test.dart @@ -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', diff --git a/test/types_edge_cases_test.dart b/test/types_edge_cases_test.dart index 65c19add..e363dc01 100644 --- a/test/types_edge_cases_test.dart +++ b/test/types_edge_cases_test.dart @@ -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, + {}, + [], + ]) { + expect( + () => JsonRpcErrorData.fromJson({ + 'code': code, + 'message': 'Bad code', + }), + throwsA( + isA() + .having((e) => e.message, 'message', contains('code')), + ), + ); + } + + for (final message in [null, false, 1, {}, []]) { + expect( + () => JsonRpcErrorData.fromJson({ + 'code': ErrorCode.invalidRequest.value, + 'message': message, + }), + throwsA( + isA() + .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( @@ -80,6 +124,22 @@ void main() { throwsA(isA()), ); }); + + test('JsonRpcError rejects malformed error object wire values', () { + for (final error in [null, false, 1, 'not-error', []]) { + expect( + () => JsonRpcMessage.fromJson({ + 'jsonrpc': '2.0', + 'id': 1, + 'error': error, + }), + throwsA( + isA() + .having((e) => e.message, 'message', contains('error')), + ), + ); + } + }); }); group('JsonRpcCancelledNotification Edge Cases', () {