From 13d9fc911d05c3f4df7b586dc8d35bb4fe39d490 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 05:41:39 -0400 Subject: [PATCH] Restrict elicitation content numbers to integers --- CHANGELOG.md | 5 +++-- lib/src/types/elicitation.dart | 28 +++++++++++++++++++--------- test/elicitation_test.dart | 21 +++++++++++++++------ test/mcp_2025_11_25_test.dart | 24 +++++++++++++++++++++--- test/mcp_2026_07_28_test.dart | 14 ++++++++++++++ 5 files changed, 72 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7de7a1ea..988ed715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,8 +91,9 @@ clamping malformed wire values to zero. - Validated MRTR `inputResponses` as `CreateMessageResult`, `ListRootsResult`, or `ElicitResult` instead of accepting arbitrary result objects. -- Allowed finite numeric `ElicitResult.content` values to match the stable and - MCP 2026 `string | number | boolean | string[]` schema. +- 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. - 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/types/elicitation.dart b/lib/src/types/elicitation.dart index f04fe841..064b63fb 100644 --- a/lib/src/types/elicitation.dart +++ b/lib/src/types/elicitation.dart @@ -301,10 +301,10 @@ class ElicitResult implements BaseResultData { Map toJson() { final resultAction = action; _validateElicitResultContentForAction(resultAction, content); - _validateElicitResultContent(content); + final normalizedContent = _normalizeElicitResultContent(content); return { 'action': resultAction, - if (content != null) 'content': content, + if (normalizedContent != null) 'content': normalizedContent, if (meta != null) '_meta': readJsonObject(meta, 'ElicitResult._meta'), }; } @@ -726,39 +726,49 @@ Map? _parseElicitResultContent(Object? content) { throw const FormatException('ElicitResult.content must be an object.'); } final result = content.cast(); - _validateElicitResultContent(result, formatException: true); - return result; + return _normalizeElicitResultContent(result, formatException: true); } -void _validateElicitResultContent( +Map? _normalizeElicitResultContent( Map? content, { bool formatException = false, }) { if (content == null) { - return; + return null; } + final normalized = {}; for (final entry in content.entries) { final value = entry.value; if (value is String || value is bool) { + normalized[entry.key] = value; + continue; + } + if (value is int) { + normalized[entry.key] = value; continue; } - if (value is num && value.isFinite) { + if (value is double && + value.isFinite && + value == value.truncateToDouble()) { + normalized[entry.key] = value.toInt(); continue; } if (value is List && value.every((item) => item is String)) { + normalized[entry.key] = List.from(value); continue; } if (formatException) { throw FormatException( - 'ElicitResult.content.${entry.key} must be string, finite number, boolean, or string[]', + 'ElicitResult.content.${entry.key} must be string, integer, boolean, or string[]', ); } throw ArgumentError.value( value, 'content.${entry.key}', - 'ElicitResult content values must be string, finite number, boolean, or string[]', + 'ElicitResult content values must be string, integer, boolean, or string[]', ); } + return normalized; } void _validateElicitResultContentForAction( diff --git a/test/elicitation_test.dart b/test/elicitation_test.dart index 21afe5e9..a97efefb 100644 --- a/test/elicitation_test.dart +++ b/test/elicitation_test.dart @@ -1116,7 +1116,7 @@ void main() { 'action': 'accept', 'content': { 'text': 'value', - 'count': 3, + 'count': 3.0, 'confirmed': true, 'selections': ['a', 'b'], }, @@ -1143,13 +1143,13 @@ void main() { throwsA(isA()), ); expect( - ElicitResult.fromJson({ + () => ElicitResult.fromJson({ 'action': 'accept', 'content': { 'ratio': 0.5, }, - }).content?['ratio'], - 0.5, + }), + throwsA(isA()), ); expect( () => ElicitResult.fromJson({ @@ -1170,13 +1170,22 @@ void main() { throwsA(isA()), ); expect( - const ElicitResult( + () => const ElicitResult( action: 'accept', content: { 'ratio': 0.5, }, + ).toJson(), + throwsA(isA()), + ); + expect( + const ElicitResult( + action: 'accept', + content: { + 'count': 3.0, + }, ).toJson()['content'], - containsPair('ratio', 0.5), + containsPair('count', 3), ); expect( () => const ElicitResult( diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 87b61a13..0fec2e6e 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -330,17 +330,17 @@ void main() { action: 'accept', content: { 'text': 'answer', - 'confidence': 0.75, + 'confidence': 75, 'selection': ['a', 'b'], // List }, ); - expect(result.content?['confidence'], 0.75); + expect(result.content?['confidence'], 75); expect(result.content?['selection'], isA()); expect((result.content?['selection'] as List).first, 'a'); final json = result.toJson(); final deserialized = ElicitResult.fromJson(json); - expect(deserialized.content?['confidence'], 0.75); + expect(deserialized.content?['confidence'], 75); expect((deserialized.content?['selection'] as List).last, 'b'); }); @@ -1421,6 +1421,24 @@ void main() { ).toJson(), throwsA(isA()), ); + expect( + () => ElicitResult.fromJson({ + 'action': 'accept', + 'content': { + 'fractional': 1.5, + }, + }), + throwsA(isA()), + ); + expect( + () => const ElicitResult( + action: 'accept', + content: { + 'fractional': 1.5, + }, + ).toJson(), + throwsA(isA()), + ); expect( () => URLElicitationRequiredErrorData.fromJson({ 'elicitations': [ diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 5d86b5ac..b81db5b4 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -428,6 +428,13 @@ void main() { }), throwsA(isA()), ); + expect( + () => ElicitResult.fromJson({ + 'action': 'accept', + 'content': {'score': 1.5}, + }), + throwsA(isA()), + ); expect( () => const ElicitResult( action: 'accept', @@ -435,6 +442,13 @@ void main() { ).toJson(), throwsA(isA()), ); + expect( + () => const ElicitResult( + action: 'accept', + content: {'score': 1.5}, + ).toJson(), + throwsA(isA()), + ); }); test('rejects non-JSON sampling object values', () {