diff --git a/CHANGELOG.md b/CHANGELOG.md index 988ed715..a02bd54a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,9 @@ - Restricted numeric `ElicitResult.content` values to integers, matching the stable and MCP 2026 `string | integer | boolean | string[]` schemas while still accepting whole-number JSON numeric values. +- Made form elicitation number-schema keyword validation protocol-aware: + stable 2025 keeps integer-only `minimum`, `maximum`, and `default` values, + while MCP 2026 accepts fractional number keywords. - Rejected form elicitation schemas that provide legacy `enumNames` without the required string `enum`. - Rejected `ElicitResult.content` when the result action is `decline` or diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 44e31593..38249a4c 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -214,11 +214,18 @@ class McpClient extends Protocol { } return result; }, - (id, params, meta) => JsonRpcElicitRequest( - id: id, - elicitParams: ElicitRequest.fromJson(params ?? {}), - meta: meta, - ), + (id, params, meta) { + final protocolVersion = _protocolVersionForIncomingRequest(meta); + return JsonRpcElicitRequest( + id: id, + elicitParams: ElicitRequest.fromJson( + params ?? {}, + protocolVersion: protocolVersion, + ), + meta: meta, + protocolVersion: protocolVersion, + ); + }, ); } @@ -623,6 +630,14 @@ class McpClient extends Protocol { /// Gets the negotiated protocol version after connection. String? getProtocolVersion() => _negotiatedProtocolVersion; + String? _protocolVersionForIncomingRequest(Map? meta) { + final protocolVersion = meta?[McpMetaKey.protocolVersion]; + if (protocolVersion is String) { + return protocolVersion; + } + return _negotiatedProtocolVersion ?? _preferredProtocolVersion; + } + @override bool isRecognizedResultType(String resultType) { if (super.isRecognizedResultType(resultType)) { diff --git a/lib/src/types/elicitation.dart b/lib/src/types/elicitation.dart index 064b63fb..a8cb4932 100644 --- a/lib/src/types/elicitation.dart +++ b/lib/src/types/elicitation.dart @@ -94,7 +94,10 @@ class ElicitRequest { }) : mode = ElicitationMode.url, requestedSchema = null; - factory ElicitRequest.fromJson(Map json) { + factory ElicitRequest.fromJson( + Map json, { + String? protocolVersion, + }) { final modeValue = json['mode']; if (modeValue != null && modeValue is! String) { throw const FormatException('Elicitation mode must be a string.'); @@ -143,7 +146,10 @@ class ElicitRequest { if (requestedSchemaJson is! Map) { throw const FormatException('Form elicitation requires requestedSchema.'); } - _validateFormRequestedSchemaJson(requestedSchemaJson); + _validateFormRequestedSchemaJson( + requestedSchemaJson, + protocolVersion: protocolVersion, + ); if (url != null) { throw const FormatException('Form elicitation must not include url.'); } @@ -161,7 +167,7 @@ class ElicitRequest { ); } - void _validateShape() { + void _validateShape({String? protocolVersion}) { if (isUrlMode) { if (requestedSchema != null) { throw ArgumentError( @@ -181,7 +187,10 @@ class ElicitRequest { if (requestedSchema == null) { throw ArgumentError('Form elicitation requires requestedSchema.'); } - _validateFormRequestedSchema(requestedSchema!); + _validateFormRequestedSchema( + requestedSchema!, + protocolVersion: protocolVersion, + ); if (url != null) { throw ArgumentError('Form elicitation must not include url.'); } @@ -190,8 +199,8 @@ class ElicitRequest { } } - Map toJson() { - _validateShape(); + Map toJson({String? protocolVersion}) { + _validateShape(protocolVersion: protocolVersion); return { if (mode != null) 'mode': mode!.name, 'message': message, @@ -218,7 +227,13 @@ class JsonRpcElicitRequest extends JsonRpcRequest { required super.id, required this.elicitParams, super.meta, - }) : super(method: Method.elicitationCreate, params: elicitParams.toJson()); + String? protocolVersion, + }) : super( + method: Method.elicitationCreate, + params: elicitParams.toJson( + protocolVersion: protocolVersion ?? _protocolVersionFromMeta(meta), + ), + ); factory JsonRpcElicitRequest.fromJson(Map json) { final paramsMap = json['params'] as Map?; @@ -226,10 +241,15 @@ class JsonRpcElicitRequest extends JsonRpcRequest { throw const FormatException("Missing params for elicit request"); } final meta = extractRequestMeta(json); + final protocolVersion = _protocolVersionFromMeta(meta); return JsonRpcElicitRequest( id: parseRequestId(json['id']), - elicitParams: ElicitRequest.fromJson(paramsMap), + elicitParams: ElicitRequest.fromJson( + paramsMap, + protocolVersion: protocolVersion, + ), meta: meta, + protocolVersion: protocolVersion, ); } } @@ -432,11 +452,20 @@ typedef ElicitRequestParams = ElicitRequest; @Deprecated('Use ElicitationCompleteNotification instead') typedef ElicitationCompleteParams = ElicitationCompleteNotification; -void _validateFormRequestedSchema(ElicitationInputSchema schema) { - _validateFormRequestedSchemaJson(schema.toJson()); +void _validateFormRequestedSchema( + ElicitationInputSchema schema, { + String? protocolVersion, +}) { + _validateFormRequestedSchemaJson( + schema.toJson(), + protocolVersion: protocolVersion, + ); } -void _validateFormRequestedSchemaJson(Map json) { +void _validateFormRequestedSchemaJson( + Map json, { + String? protocolVersion, +}) { _ensureAllowedKeys( json, const {r'$schema', 'type', 'properties', 'required'}, @@ -467,6 +496,7 @@ void _validateFormRequestedSchemaJson(Map json) { _validatePrimitiveSchema( (entry.value as Map).cast(), 'ElicitRequest.requestedSchema.properties.${entry.key}', + protocolVersion: protocolVersion, ); } final required = json['required']; @@ -478,7 +508,11 @@ void _validateFormRequestedSchemaJson(Map json) { } } -void _validatePrimitiveSchema(Map json, String context) { +void _validatePrimitiveSchema( + Map json, + String context, { + String? protocolVersion, +}) { final type = json['type']; switch (type) { case 'string': @@ -499,7 +533,11 @@ void _validatePrimitiveSchema(Map json, String context) { context, ); _validatePrimitiveBaseKeywords(json, context); - _validateNumberSchemaKeywords(json, context, type as String); + _validateNumberSchemaKeywords( + json, + context, + protocolVersion: protocolVersion, + ); return; case 'boolean': _ensureAllowedKeys( @@ -532,22 +570,21 @@ void _validatePrimitiveBaseKeywords( void _validateNumberSchemaKeywords( Map json, - String context, - String type, -) { - if (type == 'integer') { - _validateOptionalIntegerKeyword(json, 'default', context); + String context, { + String? protocolVersion, +}) { + if (!_usesDraftNumberSchemaKeywords(protocolVersion)) { + for (final key in const ['default', 'minimum', 'maximum']) { + _validateOptionalIntegerKeyword(json, key, context); + } + return; } - for (final key in const ['minimum', 'maximum']) { + for (final key in const ['default', 'minimum', 'maximum']) { if (json[key] != null) { readFiniteNumber(json[key], '$context.$key'); } } - - if (type == 'number' && json['default'] != null) { - readFiniteNumber(json['default'], '$context.default'); - } } void _validateStringOrSingleEnumSchema( @@ -718,6 +755,15 @@ void _ensureAllowedKeys( } } +String? _protocolVersionFromMeta(Map? meta) { + final protocolVersion = meta?[McpMetaKey.protocolVersion]; + return protocolVersion is String ? protocolVersion : null; +} + +bool _usesDraftNumberSchemaKeywords(String? protocolVersion) { + return protocolVersion != null && isStatelessProtocolVersion(protocolVersion); +} + Map? _parseElicitResultContent(Object? content) { if (content == null) { return null; diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 534cb902..ddc82f9a 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -711,7 +711,9 @@ class InputRequest { /// Creates an embedded `elicitation/create` input request. factory InputRequest.elicit(ElicitRequest params) { - final inputParams = params.toJson()..remove('task'); + final inputParams = params.toJson( + protocolVersion: latestDraftProtocolVersion, + )..remove('task'); return InputRequest._( method: Method.elicitationCreate, params: inputParams, @@ -753,7 +755,10 @@ class InputRequest { 'legacy task metadata', ); } - ElicitRequest.fromJson(params); + ElicitRequest.fromJson( + params, + protocolVersion: latestDraftProtocolVersion, + ); return InputRequest._(method: method, params: params); case Method.samplingCreateMessage: final params = _readRequiredJsonObject( @@ -811,7 +816,10 @@ class InputRequest { if (method != Method.elicitationCreate || params == null) { throw StateError('InputRequest is not an elicitation/create request'); } - return ElicitRequest.fromJson(params!); + return ElicitRequest.fromJson( + params!, + protocolVersion: latestDraftProtocolVersion, + ); } /// The typed params for an embedded `sampling/createMessage` request. diff --git a/test/elicitation_test.dart b/test/elicitation_test.dart index a97efefb..75254bd8 100644 --- a/test/elicitation_test.dart +++ b/test/elicitation_test.dart @@ -890,6 +890,138 @@ void main() { expect(request.toJson()['requestedSchema'], isA>()); }); + test('Form elicitation defaults to stable number schema keywords', () { + Map requestWithProperty( + String name, + Map property, + ) => + { + 'message': 'Configure deployment', + 'requestedSchema': { + 'type': 'object', + 'properties': {name: property}, + }, + }; + + for (final property in >{ + 'fractionalNumberDefault': { + 'type': 'number', + 'default': 0.5, + }, + 'fractionalNumberMinimum': { + 'type': 'number', + 'minimum': 0.1, + }, + 'fractionalNumberMaximum': { + 'type': 'number', + 'maximum': 0.9, + }, + 'fractionalIntegerDefault': { + 'type': 'integer', + 'default': 1.5, + }, + 'fractionalIntegerMinimum': { + 'type': 'integer', + 'minimum': 1.5, + }, + 'fractionalIntegerMaximum': { + 'type': 'integer', + 'maximum': 10.5, + }, + }.entries) { + expect( + () => ElicitRequestParams.fromJson( + requestWithProperty(property.key, property.value), + ), + throwsA(isA()), + ); + } + + expect( + () => ElicitRequestParams.form( + message: 'Configure deployment', + requestedSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number( + minimum: 0.1, + maximum: 0.9, + defaultValue: 0.5, + ), + }, + ), + ).toJson(), + throwsA(isA()), + ); + }); + + test('Draft form elicitation accepts fractional number schema keywords', + () { + final params = { + 'mode': 'form', + 'message': 'Configure deployment', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'ratio': { + 'type': 'number', + 'minimum': 0.1, + 'maximum': 0.9, + 'default': 0.5, + }, + 'count': { + 'type': 'integer', + 'minimum': 0.5, + 'maximum': 10.5, + 'default': 1.5, + }, + }, + }, + }; + + final request = ElicitRequestParams.fromJson( + params, + protocolVersion: draftProtocolVersion2026_07_28, + ); + final draftJson = request.toJson( + protocolVersion: draftProtocolVersion2026_07_28, + ); + final schema = draftJson['requestedSchema'] as Map; + final properties = schema['properties'] as Map; + + expect( + (properties['ratio'] as Map)['default'], + 0.5, + ); + expect( + (properties['count'] as Map)['default'], + 1.5, + ); + expect( + (properties['count'] as Map)['maximum'], + 10.5, + ); + expect( + () => request.toJson(), + throwsA(isA()), + ); + + final rpc = JsonRpcElicitRequest.fromJson({ + 'id': 1, + 'params': { + ...params, + '_meta': { + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + }, + }, + }); + final rpcSchema = rpc.params!['requestedSchema'] as Map; + final rpcProperties = rpcSchema['properties'] as Map; + expect( + (rpcProperties['ratio'] as Map)['minimum'], + 0.1, + ); + }); + test('Form elicitation rejects non-spec schema shapes', () { Map requestWithProperty( String name, diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 0fec2e6e..eaf3f221 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1394,6 +1394,21 @@ void main() { ); expect(request.toJson()['requestedSchema']['type'], 'object'); + expect( + () => ElicitRequest.form( + message: 'Fractional bounds', + requestedSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number( + minimum: 0.1, + maximum: 0.9, + defaultValue: 0.5, + ), + }, + ), + ).toJson(), + throwsA(isA()), + ); expect( () => const ElicitRequest.form( message: 'Nested', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index b81db5b4..ac056d59 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -393,6 +393,44 @@ void main() { ); }); + test('allows fractional elicitation number schema keywords', () { + final request = ElicitRequestParams.form( + message: 'Configure ratio', + requestedSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number( + minimum: 0.1, + maximum: 0.9, + defaultValue: 0.5, + ), + 'count': JsonSchema.integer( + minimum: 0.5, + maximum: 10.5, + defaultValue: 1.5, + ), + }, + ), + ); + + final json = request.toJson( + protocolVersion: draftProtocolVersion2026_07_28, + ); + final schema = json['requestedSchema'] as Map; + final properties = schema['properties'] as Map; + expect((properties['ratio'] as Map)['minimum'], 0.1); + expect((properties['count'] as Map)['default'], 1.5); + expect((properties['count'] as Map)['maximum'], 10.5); + + final inputRequest = InputRequest.elicit(request); + final inputSchema = + inputRequest.params!['requestedSchema'] as Map; + final inputProperties = inputSchema['properties'] as Map; + expect( + (inputProperties['ratio'] as Map)['default'], + 0.5, + ); + }); + test('rejects non-finite JSON numbers', () { expect( () => ProgressNotification.fromJson({