diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/utils/McpJsonSchemaGenerator.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/utils/McpJsonSchemaGenerator.java index f9aada5681..3197cf541a 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/utils/McpJsonSchemaGenerator.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/utils/McpJsonSchemaGenerator.java @@ -83,7 +83,8 @@ public final class McpJsonSchemaGenerator { * spring-ai-model's JsonSchemaGenerator. */ static { - Module jacksonModule = new JacksonSchemaModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED); + Module jacksonModule = new JacksonSchemaModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED, + JacksonOption.FLATTENED_ENUMS_FROM_JSONVALUE, JacksonOption.FLATTENED_ENUMS_FROM_JSONPROPERTY); Module openApiModule = new Swagger2Module(); Module springAiSchemaModule = PROPERTY_REQUIRED_BY_DEFAULT ? new McpSpringAiSchemaModule() : new McpSpringAiSchemaModule(McpSpringAiSchemaModule.Option.PROPERTY_REQUIRED_FALSE_BY_DEFAULT); diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/method/tool/utils/McpJsonSchemaGeneratorTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/method/tool/utils/McpJsonSchemaGeneratorTests.java index 8c448fd710..5eb30fd887 100644 --- a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/method/tool/utils/McpJsonSchemaGeneratorTests.java +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/method/tool/utils/McpJsonSchemaGeneratorTests.java @@ -19,6 +19,8 @@ import java.lang.reflect.Method; import java.util.List; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonValue; import org.junit.jupiter.api.Test; import tools.jackson.databind.JsonNode; @@ -126,6 +128,31 @@ void generateSchemaForMethodRewritesPeerDefinitionRefsAfterRename() throws Excep .isEqualTo("#/$defs/Filter_2"); } + // gh-1985: enum constants annotated with @JsonProperty must surface their custom + // values in the generated schema, matching Jackson's deserialization behavior. + @Test + void generateSchemaForMethodWithCustomEnumValues() throws Exception { + Method method = TestMethods.class.getDeclaredMethod("useCustomEnum", CustomEnum.class); + + String schema = McpJsonSchemaGenerator.generateForMethodInput(method); + JsonNode schemaNode = jsonHelper.fromJson(schema, JsonNode.class); + + assertThat(schemaNode.at("/properties/status/enum")).map(JsonNode::asString) + .containsExactly("value.a", "value.b"); + } + + // gh-1985: enums serialized through a @JsonValue method must surface those values + // in the generated schema as well. + @Test + void generateSchemaForMethodWithJsonValueEnum() throws Exception { + Method method = TestMethods.class.getDeclaredMethod("useJsonValueEnum", TemperatureUnit.class); + + String schema = McpJsonSchemaGenerator.generateForMethodInput(method); + JsonNode schemaNode = jsonHelper.fromJson(schema, JsonNode.class); + + assertThat(schemaNode.at("/properties/unit/enum")).map(JsonNode::asString).containsExactly("C", "F"); + } + @Test void generateForMethodInputThrowsWhenMethodIsNull() { assertThatThrownBy(() -> McpJsonSchemaGenerator.generateForMethodInput(null)) @@ -165,6 +192,37 @@ public void collidingSimpleNameMethod(OuterA.SearchRequest a, OuterB.SearchReque public void peerReferenceMethod(PeerA.SearchRequest a, PeerB.SearchRequest b) { } + public void useCustomEnum(CustomEnum status) { + } + + public void useJsonValueEnum(TemperatureUnit unit) { + } + + } + + enum CustomEnum { + + @JsonProperty("value.a") + A, @JsonProperty("value.b") + B + + } + + public enum TemperatureUnit { + + CELSIUS("C"), FAHRENHEIT("F"); + + private final String symbol; + + TemperatureUnit(String symbol) { + this.symbol = symbol; + } + + @JsonValue + public String symbol() { + return this.symbol; + } + } record RecursiveFilter(String field, String operator, List filters) { diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc index 7a075b9d81..ff77a68752 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc @@ -4,6 +4,16 @@ [[upgrading-to-2-0-1]] == Upgrading to 2.0.1 +=== JSON Schemas Honor Custom Enum Values from `@JsonValue` and `@JsonProperty` + +JSON schemas generated for tool calling and structured output now use Jackson's serialized form for enums that declare a `@JsonValue` method or annotate all constants with `@JsonProperty`. +Previously the schema advertised the Java constant names while deserialization expected the custom values, causing an `InvalidFormatException` when the model followed the schema. + +==== Impact + +Generated schemas change only for enums using those annotations: the `enum` values now match what Jackson accepts during deserialization. +Enums without these annotations keep emitting their constant names. + === Redis Chat Memory Repository Auto-Configuration Module Renaming The Redis chat memory auto-configurations module renamed to `spring-ai-autoconfigure-model-chat-memory-repository-redis`. diff --git a/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java b/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java index db60373d88..ddccd13c59 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java @@ -71,6 +71,10 @@ * If none of these annotations are present, the default behavior is to consider the * property as required and not to include a description. *

+ * Enum values are aligned with Jackson's serialized form when the enum declares a + * {@code @JsonValue} method or all of its constants are annotated with + * {@code @JsonProperty}. + *

* * @author Thomas Vitale * @author Sebastien Deleuze @@ -95,7 +99,8 @@ public final class JsonSchemaGenerator { */ static { Module jacksonModule = new JacksonSchemaModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED, - JacksonOption.RESPECT_JSONPROPERTY_ORDER); + JacksonOption.RESPECT_JSONPROPERTY_ORDER, JacksonOption.FLATTENED_ENUMS_FROM_JSONVALUE, + JacksonOption.FLATTENED_ENUMS_FROM_JSONPROPERTY); Module openApiModule = new Swagger2Module(); Module springAiSchemaModule = PROPERTY_REQUIRED_BY_DEFAULT ? new SpringAiSchemaModule() : new SpringAiSchemaModule(SpringAiSchemaModule.Option.PROPERTY_REQUIRED_FALSE_BY_DEFAULT); diff --git a/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaUtils.java b/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaUtils.java index a5690d6087..d61671ce36 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaUtils.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaUtils.java @@ -57,7 +57,8 @@ public final class JsonSchemaUtils { private static final SchemaGenerator schemaGenerator; static { - JacksonSchemaModule jacksonModule = new JacksonSchemaModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED); + JacksonSchemaModule jacksonModule = new JacksonSchemaModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED, + JacksonOption.FLATTENED_ENUMS_FROM_JSONVALUE, JacksonOption.FLATTENED_ENUMS_FROM_JSONPROPERTY); Swagger2Module swaggerModule = new Swagger2Module(); SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, diff --git a/spring-ai-model/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java b/spring-ai-model/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java index 83b7b6d3cd..26b5541983 100644 --- a/spring-ai-model/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java +++ b/spring-ai-model/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java @@ -23,6 +23,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonValue; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -133,6 +134,39 @@ record TestClassWithToolParam(@ToolParam(required = true, description = "A requi record TestClassWithNullable(String requiredField, @Nullable String optionalField) { } + record TestClassWithCustomEnumValues(CustomEnum values) { + + enum CustomEnum { + + @JsonProperty("value.a") + A, @JsonProperty("value.b") + B + + } + + } + + record TestClassWithJsonValueEnum(TemperatureUnit unit) { + + public enum TemperatureUnit { + + CELSIUS("C"), FAHRENHEIT("F"); + + private final String symbol; + + TemperatureUnit(String symbol) { + this.symbol = symbol; + } + + @JsonValue + public String symbol() { + return this.symbol; + } + + } + + } + @Nested class ConverterTest { @@ -150,6 +184,24 @@ void convertClassWithDateType() { assertThat(testClass.getSomeString()).isEqualTo(LocalDate.of(2020, 1, 1)); } + @Test + void convertEnumWithCustomJsonPropertyValues() { + var converter = new BeanOutputConverter<>(TestClassWithCustomEnumValues.class); + // the generated schema must advertise the same enum values that the + // deserializer accepts + assertThat(converter.getJsonSchema()).contains("value.a", "value.b"); + var result = converter.convert("{ \"values\": \"value.a\" }"); + assertThat(result.values()).isEqualTo(TestClassWithCustomEnumValues.CustomEnum.A); + } + + @Test + void convertEnumWithJsonValueMappings() { + var converter = new BeanOutputConverter<>(TestClassWithJsonValueEnum.class); + assertThat(converter.getJsonSchema()).contains("\"C\"", "\"F\""); + var result = converter.convert("{ \"unit\": \"C\" }"); + assertThat(result.unit()).isEqualTo(TestClassWithJsonValueEnum.TemperatureUnit.CELSIUS); + } + @Test void convertTypeReference() { var converter = new BeanOutputConverter<>(new ParameterizedTypeReference() { diff --git a/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java b/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java index e53d5ee2b5..96d92859ff 100644 --- a/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java +++ b/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java @@ -36,6 +36,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonValue; import io.swagger.v3.oas.annotations.media.Schema; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; @@ -748,6 +749,50 @@ void generateSchemaForEnum() { assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); } + @Test + void generateSchemaForEnumWithJsonPropertyValues() { + String schema = JsonSchemaGenerator.generateForType(CustomEnumWrapper.class); + String expectedJsonSchema = """ + { + "$schema" : "https://json-schema.org/draft/2020-12/schema", + "type" : "object", + "properties" : { + "values" : { + "type" : "string", + "enum" : [ "value.a", "value.b" ] + } + }, + "required" : [ "values" ], + "additionalProperties" : false + } + """; + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForEnumWithJsonValue() { + String schema = JsonSchemaGenerator.generateForType(TemperatureUnit.class); + String expectedJsonSchema = """ + { + "$schema" : "https://json-schema.org/draft/2020-12/schema", + "type" : "string", + "enum" : [ "C", "F" ] + } + """; + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForMethodInputWithCustomEnumValues() throws Exception { + Method method = CustomEnumMethods.class.getDeclaredMethod("setTemperature", CustomEnumWrapper.CustomEnum.class, + TemperatureUnit.class); + String schema = JsonSchemaGenerator.generateForMethodInput(method); + JsonNode schemaNode = JacksonUtils.getDefaultJsonMapper().readTree(schema); + assertThat(schemaNode.at("/properties/value/enum")).map(JsonNode::asString) + .containsExactly("value.a", "value.b"); + assertThat(schemaNode.at("/properties/unit/enum")).map(JsonNode::asString).containsExactly("C", "F"); + } + @Test void generateSchemaForTypeWithJSpecifyNullableField() { String schema = JsonSchemaGenerator.generateForType(JSpecifyNullablePerson.class); @@ -1121,6 +1166,42 @@ record WithMapField(String name, Map scores) { } + record CustomEnumWrapper(CustomEnum values) { + + enum CustomEnum { + + @JsonProperty("value.a") + A, @JsonProperty("value.b") + B + + } + + } + + public enum TemperatureUnit { + + CELSIUS("C"), FAHRENHEIT("F"); + + private final String symbol; + + TemperatureUnit(String symbol) { + this.symbol = symbol; + } + + @JsonValue + public String symbol() { + return this.symbol; + } + + } + + static class CustomEnumMethods { + + public void setTemperature(CustomEnumWrapper.CustomEnum value, TemperatureUnit unit) { + } + + } + @JsonPropertyOrder({ "accountId", "accountName", "currency", "totals" }) record OrderedStatement(@JsonProperty(required = true) String accountId, @JsonProperty(required = true) String accountName, String currency, Map totals) { diff --git a/spring-ai-model/src/test/java/org/springframework/ai/util/json/schema/JsonSchemaUtilsTests.java b/spring-ai-model/src/test/java/org/springframework/ai/util/json/schema/JsonSchemaUtilsTests.java index 14494288c8..5f574964a1 100644 --- a/spring-ai-model/src/test/java/org/springframework/ai/util/json/schema/JsonSchemaUtilsTests.java +++ b/spring-ai-model/src/test/java/org/springframework/ai/util/json/schema/JsonSchemaUtilsTests.java @@ -28,7 +28,9 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonValue; import org.junit.jupiter.api.Test; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.node.ObjectNode; import org.springframework.ai.util.JsonHelper; @@ -204,10 +206,61 @@ private static List generateConcurrently(Callable generator) throws Ex } } + // gh-1985: enum constants annotated with @JsonProperty must surface their custom + // values in the generated schema, matching Jackson's deserialization behavior. + @Test + void getJsonSchemaHonorsJsonPropertyEnumValues() { + ObjectNode schema = JsonSchemaUtils.getJsonSchema(CustomEnumHolder.class); + + assertThat(schema.at("/properties/status/enum")).map(JsonNode::asString).containsExactly("value.a", "value.b"); + } + + // gh-1985: enums serialized through a @JsonValue method must surface those values + // in the generated schema as well. + @Test + void getJsonSchemaHonorsJsonValueEnumValues() { + ObjectNode schema = JsonSchemaUtils.getJsonSchema(UnitHolder.class); + + assertThat(schema.at("/properties/unit/enum")).map(JsonNode::asString).containsExactly("C", "F"); + } + @JsonPropertyOrder({ "accountId", "accountName", "currency", "totals" }) record OrderedStatement(@JsonProperty(required = true) String accountId, @JsonProperty(required = true) String accountName, String currency, Map totals) { } + record CustomEnumHolder(CustomEnum status) { + + enum CustomEnum { + + @JsonProperty("value.a") + A, @JsonProperty("value.b") + B + + } + + } + + record UnitHolder(TemperatureUnit unit) { + + } + + public enum TemperatureUnit { + + CELSIUS("C"), FAHRENHEIT("F"); + + private final String symbol; + + TemperatureUnit(String symbol) { + this.symbol = symbol; + } + + @JsonValue + public String symbol() { + return this.symbol; + } + + } + } diff --git a/spring-ai-model/src/test/kotlin/org/springframework/ai/util/json/schema/JsonSchemaGeneratorKotlinTests.kt b/spring-ai-model/src/test/kotlin/org/springframework/ai/util/json/schema/JsonSchemaGeneratorKotlinTests.kt index 439633b05e..92455bb466 100644 --- a/spring-ai-model/src/test/kotlin/org/springframework/ai/util/json/schema/JsonSchemaGeneratorKotlinTests.kt +++ b/spring-ai-model/src/test/kotlin/org/springframework/ai/util/json/schema/JsonSchemaGeneratorKotlinTests.kt @@ -16,6 +16,7 @@ package org.springframework.ai.util.json.schema +import com.fasterxml.jackson.annotation.JsonValue import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.springframework.ai.tool.annotation.Tool @@ -77,6 +78,17 @@ class JsonSchemaGeneratorKotlinTests { assertThat(requiredNames(schemaNode["required"])).containsExactly("url") } + // gh-1985: victools resolves @JsonValue from methods, so Kotlin properties must use + // the get-site target for their values to surface in the generated schema. + @Test + fun `enum values follow get-site JsonValue annotation`() { + val schema = JsonSchemaGenerator.generateForType(UnitHolder::class.java) + val schemaNode = jsonMapper.readTree(schema) + val values = schemaNode.at("/properties/unit/enum").iterator().asSequence().map { it.asString() }.toList() + + assertThat(values).containsExactly("C", "F") + } + private fun requiredNames(required: JsonNode?): List { if (required == null || required.isNull) { return emptyList() @@ -90,6 +102,14 @@ class JsonSchemaGeneratorKotlinTests { private data class SearchRequest(val query: String, val filter: String?) + private data class UnitHolder(val unit: TemperatureUnit) + + enum class TemperatureUnit(@get:JsonValue val symbol: String) { + + CELSIUS("C"), FAHRENHEIT("F") + + } + private class SearchTools { @Tool(description = "Search")