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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions lib/src/shared/transport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,22 @@ 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<void> sendWithRequestId(
JsonRpcMessage message, {
RequestId? relatedRequestId,
});
}

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
Expand Down
36 changes: 24 additions & 12 deletions lib/src/types/json_rpc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
);
}

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

Expand All @@ -204,6 +212,10 @@ RequestId? _parseErrorResponseId(Map<String, dynamic> 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])?$',
);
Expand Down Expand Up @@ -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<String, dynamic>? validateRequestMeta(
Map<String, dynamic>? meta, {
bool validateKeys = false,
Expand Down Expand Up @@ -449,7 +461,7 @@ class JsonRpcRequest extends JsonRpcMessage {
@override
Map<String, dynamic> toJson() => {
'jsonrpc': jsonrpc,
'id': id,
'id': _requestIdToJson(id, 'JsonRpcRequest.id'),
'method': method,
if (params != null || meta != null)
'params': <String, dynamic>{
Expand Down Expand Up @@ -506,7 +518,7 @@ class JsonRpcResponse extends JsonRpcMessage {
@override
Map<String, dynamic> toJson() => {
'jsonrpc': jsonrpc,
'id': id,
'id': _requestIdToJson(id, 'JsonRpcResponse.id'),
'result': <String, dynamic>{
...readJsonObject(result, 'JsonRpcResponse.result'),
if (meta != null)
Expand Down Expand Up @@ -596,7 +608,7 @@ class JsonRpcError extends JsonRpcMessage {
@override
Map<String, dynamic> toJson() => {
'jsonrpc': jsonrpc,
if (id != null) 'id': id,
if (id != null) 'id': _requestIdToJson(id, 'JsonRpcError.id'),
'error': error.toJson(),
};
}
Expand Down
4 changes: 2 additions & 2 deletions lib/src/types/misc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class CancelledNotification {
);

Map<String, dynamic> toJson() => {
'requestId': requestId,
'requestId': parseRequestId(requestId, fieldName: 'requestId'),
if (reason != null) 'reason': reason,
};
}
Expand Down Expand Up @@ -148,7 +148,7 @@ class ProgressNotification implements Progress {

@override
Map<String, dynamic> toJson() => {
'progressToken': progressToken,
'progressToken': parseProgressToken(progressToken),
...Progress(
progress: progress,
total: total,
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp_dart_cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 68 additions & 10 deletions packages/mcp_dart_cli/lib/src/conformance_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,27 @@ 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',
description:
'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',
Expand Down Expand Up @@ -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,
),
];
Expand Down Expand Up @@ -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(
Expand All @@ -311,28 +328,28 @@ _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: <String, dynamic>{
'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: <String, dynamic>{
'jsonrpc': jsonRpcVersion,
'id': random.nextBool() ? numericId : stringId,
'id': random.nextBool() ? numericIdValue : stringId,
'result': <String, dynamic>{},
},
expectation: _expectResponseIdRoundTrip,
),
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: <String, dynamic>{
'jsonrpc': jsonRpcVersion,
'method': Method.notificationsProgress,
Expand All @@ -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: <String, dynamic>{
'jsonrpc': jsonRpcVersion,
'id': random.nextBool() ? numericId : stringId,
'id': random.nextBool() ? numericIdValue : stringId,
'error': <String, dynamic>{
'code': ErrorCode.invalidRequest.value,
'message': 'generated invalid request',
Expand Down Expand Up @@ -662,6 +678,24 @@ Future<void> _preservesStringResponseId() async {
}
}

Future<void> _preservesNumericResponseId() async {
final message = JsonRpcMessage.fromJson(const <String, dynamic>{
'jsonrpc': jsonRpcVersion,
'id': 1.5,
'result': <String, dynamic>{},
});

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<JsonRpcMessage> messages, {
required RequestId id,
Expand Down Expand Up @@ -731,6 +765,30 @@ Future<void> _preservesStringProgressToken() async {
}
}

Future<void> _preservesNumericProgressToken() async {
final message = JsonRpcMessage.fromJson(const <String, dynamic>{
'jsonrpc': jsonRpcVersion,
'method': Method.notificationsProgress,
'params': <String, dynamic>{
'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<void> _advertisesLatestProtocolVersion() async {
if (latestProtocolVersion != '2025-11-25') {
throw StateError(
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp_dart_cli/test/src/conformance_command_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]),
);
Expand Down
20 changes: 20 additions & 0 deletions test/mcp_2026_07_28_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsonRpcListToolsRequest>());
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({
Expand Down
13 changes: 7 additions & 6 deletions test/shared/protocol_advanced_scenarios_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
),
);

Expand Down
Loading