Skip to content

Commit 82520ce

Browse files
authored
fix: emit standard JSON Schema for JsonEnum (#89)
* fix: emit standard JSON Schema for JsonEnum * docs: move JsonEnum changelog note to unreleased
1 parent 1d86325 commit 82520ce

8 files changed

Lines changed: 181 additions & 6 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
## Unreleased
2+
3+
### Compatibility Notes (Potentially Breaking)
4+
5+
- **`JsonEnum` wire format is now standard JSON Schema**:
6+
- `JsonEnum.toJson()` emits standard JSON Schema enum forms instead of the legacy
7+
`type: 'enum'` / `values` shape.
8+
- Legacy serialized enum input using `values` is still accepted when parsing.
9+
10+
### Reliability
11+
12+
- Fixed `JsonEnum` tool/input schema serialization to use standard JSON Schema enum output,
13+
improving compatibility with downstream consumers that reject the legacy
14+
`type: 'enum'` / `values` shape.
15+
116
## 2.1.0
217

318
### Compatibility Notes (Potentially Breaking)

lib/src/shared/json_schema/json_schema.dart

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -811,9 +811,13 @@ class JsonEnum extends JsonSchema {
811811
@override
812812
final dynamic defaultValue;
813813

814+
/// The canonical values accepted by this enum schema.
815+
List<dynamic> get normalizedValues =>
816+
values.map((value) => _normalizeEntry(value).value).toList();
817+
814818
factory JsonEnum.fromJson(Map<String, dynamic> json) {
815819
return JsonEnum(
816-
json['values'] as List<dynamic>? ?? json['enum'] as List<dynamic>? ?? [],
820+
_parseValues(json),
817821
title: json['title'] as String?,
818822
description: json['description'] as String?,
819823
defaultValue: json['default'],
@@ -822,12 +826,88 @@ class JsonEnum extends JsonSchema {
822826

823827
@override
824828
Map<String, dynamic> toJson() {
829+
final normalizedEntries =
830+
values.map((value) => _normalizeEntry(value)).toList(growable: false);
831+
final hasTitles = normalizedEntries.any((entry) => entry.title != null);
832+
final allStrings =
833+
normalizedEntries.every((entry) => entry.value is String);
834+
825835
return {
826836
if (title != null) 'title': title,
827837
if (description != null) 'description': description,
828838
if (defaultValue != null) 'default': defaultValue,
829-
'type': 'enum',
830-
'values': values,
839+
if (allStrings) 'type': 'string',
840+
if (allStrings)
841+
'enum': normalizedEntries.map((entry) => entry.value as String).toList()
842+
else if (!hasTitles)
843+
'enum': normalizedEntries.map((entry) => entry.value).toList()
844+
else
845+
'oneOf': normalizedEntries
846+
.map(
847+
(entry) => {
848+
'const': entry.value,
849+
if (entry.title != null) 'title': entry.title,
850+
},
851+
)
852+
.toList(),
853+
if (allStrings && hasTitles)
854+
'enumNames': normalizedEntries
855+
.map((entry) => entry.title ?? entry.value as String)
856+
.toList(),
831857
};
832858
}
859+
860+
static List<dynamic> _parseValues(Map<String, dynamic> json) {
861+
final legacyValues = json['values'];
862+
if (legacyValues is List) {
863+
return List<dynamic>.from(legacyValues);
864+
}
865+
866+
final enumValues = json['enum'];
867+
if (enumValues is List) {
868+
final enumNames = json['enumNames'];
869+
if (enumNames is List && enumNames.length == enumValues.length) {
870+
return List<dynamic>.generate(enumValues.length, (index) {
871+
final value = enumValues[index];
872+
final title = enumNames[index];
873+
if (title is String && title != '$value') {
874+
return {'value': value, 'title': title};
875+
}
876+
return value;
877+
});
878+
}
879+
880+
return List<dynamic>.from(enumValues);
881+
}
882+
883+
final oneOfValues = json['oneOf'];
884+
if (oneOfValues is List) {
885+
return oneOfValues.map((entry) {
886+
if (entry is Map && entry.containsKey('const')) {
887+
final value = entry['const'];
888+
final title = entry['title'];
889+
if (title is String && title != '$value') {
890+
return {'value': value, 'title': title};
891+
}
892+
return value;
893+
}
894+
895+
return entry;
896+
}).toList();
897+
}
898+
899+
return const [];
900+
}
901+
902+
static ({dynamic value, String? title}) _normalizeEntry(dynamic entry) {
903+
if (entry is Map && entry.containsKey('value')) {
904+
final title = entry['title'];
905+
return (
906+
value: entry['value'],
907+
title: title is String ? title : null,
908+
);
909+
}
910+
911+
return (value: entry, title: null);
912+
}
833913
}

lib/src/shared/json_schema/json_schema_validator.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,9 +318,9 @@ extension JsonSchemaValidation on JsonSchema {
318318
}
319319

320320
void _validateEnum(JsonEnum schema, dynamic data, List<String> path) {
321-
if (!schema.values.any((e) => _deepEquals(e, data))) {
321+
if (!schema.normalizedValues.any((value) => _deepEquals(value, data))) {
322322
throw JsonSchemaValidationException(
323-
'Value must be one of ${schema.values}',
323+
'Value must be one of ${schema.normalizedValues}',
324324
path,
325325
);
326326
}

test/mcp_2025_11_25_test.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,15 @@ void main() {
184184
expect((schema.values[1] as Map)['title'], 'Complex Option');
185185

186186
final json = schema.toJson();
187-
expect(json['values'], hasLength(2));
187+
expect(json['type'], 'string');
188+
expect(json['enum'], ['simple', 'complex']);
189+
expect(json['enumNames'], ['simple', 'Complex Option']);
190+
expect(json.containsKey('values'), isFalse);
188191

189192
final deserialized = JsonEnum.fromJson(json);
190193
expect(deserialized.values[0], 'simple');
191194
expect((deserialized.values[1] as Map)['value'], 'complex');
195+
expect((deserialized.values[1] as Map)['title'], 'Complex Option');
192196
});
193197

194198
test('ToolAnnotations SEP-???', () {

test/shared/json_schema_from_json_test.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ void main() {
2222
expect(s.enumValues, ['a', 'b']);
2323
});
2424

25+
test('parses legacy enum schema', () {
26+
final json = {
27+
'type': 'enum',
28+
'values': ['simple', 'complex'],
29+
};
30+
final schema = JsonSchema.fromJson(json);
31+
expect(schema, isA<JsonEnum>());
32+
final s = schema as JsonEnum;
33+
expect(s.values, ['simple', 'complex']);
34+
});
35+
2536
test('parses number schema', () {
2637
final json = {
2738
'type': 'number',

test/shared/json_schema_test.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,27 @@ void main() {
163163
'not': {'type': 'string'},
164164
});
165165
});
166+
167+
test('JsonEnum serializes titled string values compatibly', () {
168+
const schema = JsonEnum([
169+
'simple',
170+
{'value': 'complex', 'title': 'Complex Option'},
171+
]);
172+
173+
expect(schema.toJson(), {
174+
'type': 'string',
175+
'enum': ['simple', 'complex'],
176+
'enumNames': ['simple', 'Complex Option'],
177+
});
178+
});
179+
180+
test('JsonEnum serializes mixed primitive values as standard enum', () {
181+
const schema = JsonEnum([1, 'two', true, null]);
182+
183+
expect(schema.toJson(), {
184+
'enum': [1, 'two', true, null],
185+
});
186+
});
166187
});
167188

168189
group('JsonSchema Validation Integration', () {

test/shared/json_schema_validator_test.dart

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,21 @@ void main() {
416416
throwsA(isA<JsonSchemaValidationException>()),
417417
);
418418
});
419+
420+
test('validates titled enum values against canonical values', () {
421+
final schema = const JsonEnum([
422+
'simple',
423+
{'value': 'complex', 'title': 'Complex Option'},
424+
]);
425+
426+
schema.validate('simple');
427+
schema.validate('complex');
428+
429+
expect(
430+
() => schema.validate('Complex Option'),
431+
throwsA(isA<JsonSchemaValidationException>()),
432+
);
433+
});
419434
});
420435

421436
group('composition schemas', () {

test/tool_schema_test.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,35 @@ void main() {
143143
);
144144
});
145145

146+
test('Tool serializes JsonEnum properties as standard enum schema', () {
147+
const tool = Tool(
148+
name: 'configure_mode',
149+
inputSchema: JsonObject(
150+
properties: {
151+
'mode': JsonEnum([
152+
'simple',
153+
{'value': 'complex', 'title': 'Complex Option'},
154+
]),
155+
},
156+
),
157+
);
158+
159+
final json = tool.toJson();
160+
final modeSchema =
161+
json['inputSchema']['properties']['mode'] as Map<String, dynamic>;
162+
163+
expect(modeSchema['type'], equals('string'));
164+
expect(modeSchema['enum'], equals(['simple', 'complex']));
165+
expect(modeSchema['enumNames'], equals(['simple', 'Complex Option']));
166+
expect(modeSchema.containsKey('values'), isFalse);
167+
168+
final restored = Tool.fromJson(json);
169+
final restoredMode = (restored.inputSchema as JsonObject)
170+
.properties!['mode'] as JsonString;
171+
expect(restoredMode.enumValues, equals(['simple', 'complex']));
172+
expect(restoredMode.enumNames, equals(['simple', 'Complex Option']));
173+
});
174+
146175
test('ListToolsResult preserves tool required fields', () {
147176
final tools = [
148177
Tool(

0 commit comments

Comments
 (0)