From 8498c2ad883761f1f6d5ad471a4e7a0ea686e08d Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sun, 31 May 2026 23:50:45 -0400 Subject: [PATCH] Accept numeric JSON-RPC ids and progress tokens --- CHANGELOG.md | 3 + lib/src/shared/transport.dart | 7 +- lib/src/types/json_rpc.dart | 36 ++++--- lib/src/types/misc.dart | 4 +- packages/mcp_dart_cli/README.md | 2 +- .../lib/src/conformance_runner.dart | 78 +++++++++++++-- .../test/src/conformance_command_test.dart | 2 + test/mcp_2026_07_28_test.dart | 20 ++++ .../protocol_advanced_scenarios_test.dart | 13 +-- .../transport_api_compatibility_test.dart | 13 ++- test/types_edge_cases_test.dart | 97 ++++++++++++++++--- 11 files changed, 225 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a10b6143..8d6933f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,9 @@ tool parameter headers, including requested `Mcp-Param-*` headers. - Serialized MRTR `ElicitResult` and `ListRootsResult` input responses with the MCP 2026 embedded client-result shapes that omit common Result `_meta`. +- Accepted finite numeric JSON-RPC request IDs and progress tokens, matching + the stable and MCP 2026 `string | number` schema while continuing to reject + non-finite numbers. ## 2.2.0 diff --git a/lib/src/shared/transport.dart b/lib/src/shared/transport.dart index cf6df255..1cfddae3 100644 --- a/lib/src/shared/transport.dart +++ b/lib/src/shared/transport.dart @@ -45,13 +45,14 @@ abstract class Transport { } /// Optional capability for transports that can preserve JSON-RPC request IDs -/// with their full MCP shape (string or integer) for request/stream correlation. +/// with their full MCP shape (string or finite number) for request/stream +/// correlation. /// /// Existing custom transports can keep implementing [Transport.send] with /// `int? relatedRequestId`. Transports that need to route messages by string /// request IDs should also implement this interface. abstract class RequestIdAwareTransport { - /// Sends a JSON-RPC message while preserving a string-or-integer request ID. + /// Sends a JSON-RPC message while preserving a string-or-number request ID. Future sendWithRequestId( JsonRpcMessage message, { RequestId? relatedRequestId, @@ -59,7 +60,7 @@ abstract class RequestIdAwareTransport { } extension RequestIdAwareTransportSend on Transport { - /// Sends [message] while preserving string request IDs when the transport + /// Sends [message] while preserving non-integer request IDs when the transport /// supports [RequestIdAwareTransport]. /// /// Legacy transports receive only integer IDs, matching the existing public diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 7167bfbd..a4057710 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -158,18 +158,22 @@ typedef ProgressToken = dynamic; /// Parses a wire progress token. /// -/// MCP progress tokens are JSON strings or integers. Reject malformed wire +/// MCP progress tokens are JSON strings or finite numbers. Reject malformed wire /// shapes at decode boundaries instead of allowing dynamic values to leak into /// higher-level protocol code. ProgressToken parseProgressToken( Object? value, { String fieldName = 'progressToken', }) { - if (value is String || value is int) { + if (value is String) { + return value; + } + if (value is num && value.isFinite) { return value; } throw FormatException( - 'Invalid $fieldName: expected string or integer, got ${value.runtimeType}', + 'Invalid $fieldName: expected string or finite number, ' + 'got ${value.runtimeType}', ); } @@ -181,15 +185,19 @@ typedef RequestId = dynamic; /// Parses a JSON-RPC request identifier. /// -/// JSON-RPC/MCP request IDs are JSON strings or integers for SDK request +/// JSON-RPC/MCP request IDs are JSON strings or finite numbers for SDK request /// boundaries. Notifications omit the `id` member entirely, and responses may /// omit the `id` member for JSON-RPC error cases. RequestId parseRequestId(Object? value, {String fieldName = 'id'}) { - if (value is String || value is int) { + if (value is String) { + return value; + } + if (value is num && value.isFinite) { return value; } throw FormatException( - 'Invalid $fieldName: expected string or integer, got ${value.runtimeType}', + 'Invalid $fieldName: expected string or finite number, ' + 'got ${value.runtimeType}', ); } @@ -204,6 +212,10 @@ RequestId? _parseErrorResponseId(Map json) { return parseRequestId(json['id']); } +Object _requestIdToJson(RequestId id, String fieldName) { + return parseRequestId(id, fieldName: fieldName); +} + final _metaPrefixLabelPattern = RegExp( r'^[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?$', ); @@ -246,9 +258,9 @@ void validateMetaKeyName(String key, {String fieldName = '_meta'}) { /// Validates request metadata that can affect protocol behavior. /// -/// `_meta.progressToken` is an MCP wire token and must be a string or integer -/// when present. [validateKeys] opts in to the MCP 2026 `_meta` key-name -/// grammar without changing stable/legacy request parsing. +/// `_meta.progressToken` is an MCP wire token and must be a string or finite +/// number when present. [validateKeys] opts in to the MCP 2026 `_meta` +/// key-name grammar without changing stable/legacy request parsing. Map? validateRequestMeta( Map? meta, { bool validateKeys = false, @@ -449,7 +461,7 @@ class JsonRpcRequest extends JsonRpcMessage { @override Map toJson() => { 'jsonrpc': jsonrpc, - 'id': id, + 'id': _requestIdToJson(id, 'JsonRpcRequest.id'), 'method': method, if (params != null || meta != null) 'params': { @@ -506,7 +518,7 @@ class JsonRpcResponse extends JsonRpcMessage { @override Map toJson() => { 'jsonrpc': jsonrpc, - 'id': id, + 'id': _requestIdToJson(id, 'JsonRpcResponse.id'), 'result': { ...readJsonObject(result, 'JsonRpcResponse.result'), if (meta != null) @@ -596,7 +608,7 @@ class JsonRpcError extends JsonRpcMessage { @override Map toJson() => { 'jsonrpc': jsonrpc, - if (id != null) 'id': id, + if (id != null) 'id': _requestIdToJson(id, 'JsonRpcError.id'), 'error': error.toJson(), }; } diff --git a/lib/src/types/misc.dart b/lib/src/types/misc.dart index 9bca0286..e4670eb1 100644 --- a/lib/src/types/misc.dart +++ b/lib/src/types/misc.dart @@ -31,7 +31,7 @@ class CancelledNotification { ); Map toJson() => { - 'requestId': requestId, + 'requestId': parseRequestId(requestId, fieldName: 'requestId'), if (reason != null) 'reason': reason, }; } @@ -148,7 +148,7 @@ class ProgressNotification implements Progress { @override Map toJson() => { - 'progressToken': progressToken, + 'progressToken': parseProgressToken(progressToken), ...Progress( progress: progress, total: total, diff --git a/packages/mcp_dart_cli/README.md b/packages/mcp_dart_cli/README.md index 6697383c..5064bfab 100644 --- a/packages/mcp_dart_cli/README.md +++ b/packages/mcp_dart_cli/README.md @@ -127,7 +127,7 @@ The CLI supports `sampling/createMessage` requests from the server (often used b ### Conformance -Run built-in fixture checks, MCP 2025-11-25 spec-critical checks, and deterministic fuzz checks for MCP protocol edge cases. The fixture suite covers JSON-RPC malformed-message handling, string request IDs, string progress tokens, and advertised protocol-version support. The spec suite covers raw-wire lifecycle, capability, elicitation, task-metadata, and progress-token negative cases. +Run built-in fixture checks, MCP 2025-11-25 spec-critical checks, and deterministic fuzz checks for MCP protocol edge cases. The fixture suite covers JSON-RPC malformed-message handling, string and numeric request IDs, string and numeric progress tokens, and advertised protocol-version support. The spec suite covers raw-wire lifecycle, capability, elicitation, task-metadata, and progress-token negative cases. ```bash # Run all built-in fixture cases diff --git a/packages/mcp_dart_cli/lib/src/conformance_runner.dart b/packages/mcp_dart_cli/lib/src/conformance_runner.dart index d1987ace..b1089292 100644 --- a/packages/mcp_dart_cli/lib/src/conformance_runner.dart +++ b/packages/mcp_dart_cli/lib/src/conformance_runner.dart @@ -103,6 +103,13 @@ class ConformanceRunner { 'Parses and serializes successful responses with string JSON-RPC IDs.', check: _preservesStringResponseId, ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'jsonrpc.preserves-numeric-response-id', + description: + 'Parses and serializes successful responses with numeric JSON-RPC IDs.', + check: _preservesNumericResponseId, + ), _ConformanceCase( suite: _fixtureSuite, name: 'jsonrpc.preserves-string-progress-token', @@ -110,6 +117,13 @@ class ConformanceRunner { 'Parses and serializes progress notifications with string progress tokens.', check: _preservesStringProgressToken, ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'jsonrpc.preserves-numeric-progress-token', + description: + 'Parses and serializes progress notifications with numeric progress tokens.', + check: _preservesNumericProgressToken, + ), _ConformanceCase( suite: _fixtureSuite, name: 'protocol-version.advertises-latest-2025-11-25', @@ -151,7 +165,7 @@ class ConformanceRunner { suite: _specSuite, name: 'progress.rejects-malformed-progress-token', description: - 'Rejects progress notifications whose progressToken is not a string or integer.', + 'Rejects progress notifications whose progressToken is not a string or finite number.', check: _rejectsMalformedProgressToken, ), ]; @@ -283,8 +297,11 @@ class _GeneratedJsonRpcFixture { _GeneratedJsonRpcFixture _generatedJsonRpcFixture(Random random, int index) { final numericId = random.nextInt(1000000); + final numericIdValue = + random.nextBool() ? numericId : numericId + random.nextDouble(); final stringId = 'req-${random.nextInt(1000000)}'; - final progressToken = random.nextBool() ? numericId : 'progress-$numericId'; + final progressToken = + random.nextBool() ? numericIdValue : 'progress-$numericId'; return switch (random.nextInt(6)) { 0 => _GeneratedJsonRpcFixture( @@ -311,20 +328,20 @@ _GeneratedJsonRpcFixture _generatedJsonRpcFixture(Random random, int index) { ), 2 => _GeneratedJsonRpcFixture( name: 'fuzz.jsonrpc.request-id.$index', - description: 'Generated requests preserve string-or-integer IDs.', + description: 'Generated requests preserve string-or-number IDs.', message: { 'jsonrpc': jsonRpcVersion, - 'id': random.nextBool() ? numericId : stringId, + 'id': random.nextBool() ? numericIdValue : stringId, 'method': Method.ping, }, expectation: _expectRequestIdRoundTrip, ), 3 => _GeneratedJsonRpcFixture( name: 'fuzz.jsonrpc.response-id.$index', - description: 'Generated responses preserve string-or-integer IDs.', + description: 'Generated responses preserve string-or-number IDs.', message: { 'jsonrpc': jsonRpcVersion, - 'id': random.nextBool() ? numericId : stringId, + 'id': random.nextBool() ? numericIdValue : stringId, 'result': {}, }, expectation: _expectResponseIdRoundTrip, @@ -332,7 +349,7 @@ _GeneratedJsonRpcFixture _generatedJsonRpcFixture(Random random, int index) { 4 => _GeneratedJsonRpcFixture( name: 'fuzz.jsonrpc.progress-token.$index', description: - 'Generated progress notifications preserve string-or-integer progress tokens.', + 'Generated progress notifications preserve string-or-number progress tokens.', message: { 'jsonrpc': jsonRpcVersion, 'method': Method.notificationsProgress, @@ -346,11 +363,10 @@ _GeneratedJsonRpcFixture _generatedJsonRpcFixture(Random random, int index) { ), _ => _GeneratedJsonRpcFixture( name: 'fuzz.jsonrpc.error-id.$index', - description: - 'Generated error responses preserve string-or-integer IDs.', + description: 'Generated error responses preserve string-or-number IDs.', message: { 'jsonrpc': jsonRpcVersion, - 'id': random.nextBool() ? numericId : stringId, + 'id': random.nextBool() ? numericIdValue : stringId, 'error': { 'code': ErrorCode.invalidRequest.value, 'message': 'generated invalid request', @@ -662,6 +678,24 @@ Future _preservesStringResponseId() async { } } +Future _preservesNumericResponseId() async { + final message = JsonRpcMessage.fromJson(const { + 'jsonrpc': jsonRpcVersion, + 'id': 1.5, + 'result': {}, + }); + + if (message is! JsonRpcResponse) { + throw StateError('Expected JsonRpcResponse, got ${message.runtimeType}.'); + } + if (message.id != 1.5) { + throw StateError('Expected numeric response ID to be preserved.'); + } + if (message.toJson()['id'] != 1.5) { + throw StateError('Expected serialized response ID to stay numeric.'); + } +} + JsonRpcResponse _expectSingleErrorFreeResponse( List messages, { required RequestId id, @@ -731,6 +765,30 @@ Future _preservesStringProgressToken() async { } } +Future _preservesNumericProgressToken() async { + final message = JsonRpcMessage.fromJson(const { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, + 'params': { + 'progressToken': 1.5, + 'progress': 1, + 'total': 2, + }, + }); + + if (message is! JsonRpcProgressNotification) { + throw StateError( + 'Expected JsonRpcProgressNotification, got ${message.runtimeType}.', + ); + } + if (message.progressParams.progressToken != 1.5) { + throw StateError('Expected numeric progress token to be preserved.'); + } + if (message.toJson()['params']['progressToken'] != 1.5) { + throw StateError('Expected serialized progress token to stay numeric.'); + } +} + Future _advertisesLatestProtocolVersion() async { if (latestProtocolVersion != '2025-11-25') { throw StateError( 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 1f303dd1..55467c3d 100644 --- a/packages/mcp_dart_cli/test/src/conformance_command_test.dart +++ b/packages/mcp_dart_cli/test/src/conformance_command_test.dart @@ -21,7 +21,9 @@ void main() { 'jsonrpc.rejects-invalid-version', 'jsonrpc.rejects-malformed-message', 'jsonrpc.preserves-string-response-id', + 'jsonrpc.preserves-numeric-response-id', 'jsonrpc.preserves-string-progress-token', + 'jsonrpc.preserves-numeric-progress-token', 'protocol-version.advertises-latest-2025-11-25', ]), ); diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index ef96fa37..500220c8 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -322,6 +322,26 @@ void main() { } }); + test('preserves finite numeric request ids and progress tokens', () { + final message = JsonRpcMessage.fromJson( + const { + 'jsonrpc': jsonRpcVersion, + 'id': 1.5, + 'method': Method.toolsList, + 'params': { + '_meta': {'progressToken': 2.5}, + }, + }, + ); + + expect(message, isA()); + final request = message as JsonRpcListToolsRequest; + expect(request.id, 1.5); + expect(request.progressToken, 2.5); + expect(request.toJson()['id'], 1.5); + expect(request.toJson()['params']['_meta']['progressToken'], 2.5); + }); + test('rejects URL elicitation relative URI values', () { expect( () => ElicitRequestParams.fromJson({ diff --git a/test/shared/protocol_advanced_scenarios_test.dart b/test/shared/protocol_advanced_scenarios_test.dart index 0d65f9a1..29bb6985 100644 --- a/test/shared/protocol_advanced_scenarios_test.dart +++ b/test/shared/protocol_advanced_scenarios_test.dart @@ -103,12 +103,13 @@ void main() { // Send progress notification with an invalid progressToken type. transport.receiveMessage( - JsonRpcProgressNotification( - progressParams: const ProgressNotificationParams( - progressToken: false, - progress: 50, - total: 100, - ), + const JsonRpcNotification( + method: Method.notificationsProgress, + params: { + 'progressToken': false, + 'progress': 50, + 'total': 100, + }, ), ); diff --git a/test/shared/transport_api_compatibility_test.dart b/test/shared/transport_api_compatibility_test.dart index d458b7c2..9a2f2ab0 100644 --- a/test/shared/transport_api_compatibility_test.dart +++ b/test/shared/transport_api_compatibility_test.dart @@ -30,7 +30,7 @@ class LegacyTransport implements Transport { Future start() async {} } -class StringAwareTransport extends LegacyTransport +class FullRequestIdAwareTransport extends LegacyTransport implements RequestIdAwareTransport { RequestId? lastRequestIdAwareRelatedRequestId; @@ -59,9 +59,9 @@ void main() { expect(transport.lastRelatedRequestId, isNull); }); - test('request-id-aware transports preserve string relatedRequestId', + test('request-id-aware transports preserve non-integer relatedRequestId', () async { - final transport = StringAwareTransport(); + final transport = FullRequestIdAwareTransport(); await transport.sendPreservingRequestId( const JsonRpcNotification(method: 'test/notification'), @@ -71,6 +71,13 @@ void main() { expect(transport.lastMessage, isA()); expect(transport.lastRelatedRequestId, isNull); expect(transport.lastRequestIdAwareRelatedRequestId, 'client-req-1'); + + await transport.sendPreservingRequestId( + const JsonRpcNotification(method: 'test/notification'), + relatedRequestId: 1.5, + ); + + expect(transport.lastRequestIdAwareRelatedRequestId, 1.5); }); }); } diff --git a/test/types_edge_cases_test.dart b/test/types_edge_cases_test.dart index e25a69ec..ea4723ee 100644 --- a/test/types_edge_cases_test.dart +++ b/test/types_edge_cases_test.dart @@ -91,7 +91,14 @@ void main() { }); test('rejects malformed requestId wire values', () { - for (final requestId in [null, true, {}, []]) { + for (final requestId in [ + null, + true, + double.nan, + double.infinity, + {}, + [], + ]) { expect( () => JsonRpcCancelledNotification.fromJson({ 'jsonrpc': '2.0', @@ -106,10 +113,20 @@ void main() { ), ); } + + expect( + () => const CancelledNotificationParams( + requestId: double.nan, + ).toJson(), + throwsA( + isA() + .having((e) => e.message, 'message', contains('requestId')), + ), + ); }); - test('preserves string and integer requestId wire values', () { - for (final requestId in [123, 'request-123']) { + test('preserves string and finite number requestId wire values', () { + for (final requestId in [123, 123.5, 'request-123']) { final notification = JsonRpcCancelledNotification.fromJson({ 'jsonrpc': '2.0', 'method': 'notifications/cancelled', @@ -237,6 +254,8 @@ void main() { for (final progressToken in [ null, false, + double.nan, + double.infinity, {}, [], ]) { @@ -258,10 +277,21 @@ void main() { ), ); } + + expect( + () => const ProgressNotification( + progressToken: double.nan, + progress: 1, + ).toJson(), + throwsA( + isA() + .having((e) => e.message, 'message', contains('progressToken')), + ), + ); }); - test('preserves string and integer progressToken wire values', () { - for (final progressToken in [123, 'progress-123']) { + test('preserves string and finite number progressToken wire values', () { + for (final progressToken in [123, 123.5, 'progress-123']) { final notification = JsonRpcProgressNotification.fromJson({ 'jsonrpc': '2.0', 'method': 'notifications/progress', @@ -311,7 +341,14 @@ void main() { }); test('rejects malformed request id wire values', () { - for (final id in [null, false, 1.5, {}, []]) { + for (final id in [ + null, + false, + double.nan, + double.infinity, + {}, + [], + ]) { expect( () => JsonRpcMessage.fromJson({ 'jsonrpc': '2.0', @@ -324,10 +361,24 @@ void main() { ), ); } + + expect( + () => const JsonRpcRequest( + id: double.nan, + method: 'unknown/request', + ).toJson(), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('JsonRpcRequest.id'), + ), + ), + ); }); - test('preserves string and integer request ids', () { - for (final id in [123, 'request-123']) { + test('preserves string and finite number request ids', () { + for (final id in [123, 123.5, 'request-123']) { final message = JsonRpcMessage.fromJson({ 'jsonrpc': '2.0', 'id': id, @@ -341,7 +392,14 @@ void main() { }); test('rejects malformed request progressToken wire values', () { - for (final token in [null, false, 1.5, {}, []]) { + for (final token in [ + null, + false, + double.nan, + double.infinity, + {}, + [], + ]) { expect( () => JsonRpcMessage.fromJson({ 'jsonrpc': '2.0', @@ -394,8 +452,9 @@ void main() { ); }); - test('preserves string and integer request progressToken wire values', () { - for (final token in [123, 'progress-123']) { + test('preserves string and finite number request progressToken wire values', + () { + for (final token in [123, 123.5, 'progress-123']) { final message = JsonRpcMessage.fromJson({ 'jsonrpc': '2.0', 'id': 'request-1', @@ -452,7 +511,13 @@ void main() { }); test('rejects malformed response id wire values', () { - for (final id in [false, 1.5, {}, []]) { + for (final id in [ + false, + double.nan, + double.infinity, + {}, + [], + ]) { expect( () => JsonRpcMessage.fromJson({ 'jsonrpc': '2.0', @@ -480,7 +545,13 @@ void main() { }); test('rejects malformed error id wire values', () { - for (final id in [false, 1.5, {}, []]) { + for (final id in [ + false, + double.nan, + double.infinity, + {}, + [], + ]) { expect( () => JsonRpcMessage.fromJson({ 'jsonrpc': '2.0',