From 938e10e394ccc3a0e6bcb9b0ca55b11840d18e6f Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Wed, 10 Jun 2026 16:21:27 +0200 Subject: [PATCH 1/4] Add enum schemas for form-based elicitation matching SEP-1330 Closes #691 Signed-off-by: Daniel Garnier-Moiroux --- .../server/ConformanceServlet.java | 83 ++- .../modelcontextprotocol/spec/McpSchema.java | 667 +++++++++++++++++- .../spec/McpSchemaTests.java | 313 ++++++++ 3 files changed, 1032 insertions(+), 31 deletions(-) diff --git a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java index dafa60b45..016fd8362 100644 --- a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java +++ b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java @@ -5,11 +5,12 @@ import java.util.List; import java.util.Map; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator; import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; -import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.AudioContent; import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; @@ -43,6 +44,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import static io.modelcontextprotocol.spec.McpSchema.EnumSchemaOption; +import static io.modelcontextprotocol.spec.McpSchema.JSON_SCHEMA_DIALECT_2020_12; +import static io.modelcontextprotocol.spec.McpSchema.LegacyTitledEnumSchema; +import static io.modelcontextprotocol.spec.McpSchema.TitledMultiSelectEnumSchema; +import static io.modelcontextprotocol.spec.McpSchema.TitledMultiSelectItems; +import static io.modelcontextprotocol.spec.McpSchema.TitledSingleSelectEnumSchema; +import static io.modelcontextprotocol.spec.McpSchema.UntitledMultiSelectEnumSchema; +import static io.modelcontextprotocol.spec.McpSchema.UntitledMultiSelectItems; +import static io.modelcontextprotocol.spec.McpSchema.UntitledSingleSelectEnumSchema; + public class ConformanceServlet { private static final Logger logger = LoggerFactory.getLogger(ConformanceServlet.class); @@ -141,6 +152,7 @@ private static Tomcat createEmbeddedTomcat(HttpServletStreamableServerTransportP return tomcat; } + @SuppressWarnings("deprecation") private static List createToolSpecs() { return List.of( // test_simple_text - Returns simple text content @@ -406,8 +418,8 @@ private static List createToolSpecs() { // json_schema_2020_12_tool - SEP-1613 dialect/keyword preservation McpServerFeatures.SyncToolSpecification.builder() .tool(Tool - .builder("json_schema_2020_12_tool", Map.of("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12, - "type", "object", "$defs", + .builder("json_schema_2020_12_tool", Map.of("$schema", JSON_SCHEMA_DIALECT_2020_12, "type", + "object", "$defs", Map.of("address", Map.of("type", "object", "properties", Map.of("street", Map.of("type", "string"), "city", @@ -434,33 +446,44 @@ private static List createToolSpecs() { .callHandler((exchange, request) -> { logger.info("Tool 'test_elicitation_sep1330_enums' called"); - // Create schema with all 5 enum variants - Map requestedSchema = Map.of("type", "object", "properties", Map.of( - // 1. Untitled single-select - "untitledSingle", - Map.of("type", "string", "enum", List.of("option1", "option2", "option3")), - // 2. Titled single-select using oneOf with const/title - "titledSingle", - Map.of("type", "string", "oneOf", - List.of(Map.of("const", "value1", "title", "First Option"), - Map.of("const", "value2", "title", "Second Option"), - Map.of("const", "value3", "title", "Third Option"))), - // 3. Legacy titled using enumNames (deprecated) - "legacyEnum", - Map.of("type", "string", "enum", List.of("opt1", "opt2", "opt3"), "enumNames", - List.of("Option One", "Option Two", "Option Three")), - // 4. Untitled multi-select - "untitledMulti", - Map.of("type", "array", "items", - Map.of("type", "string", "enum", List.of("option1", "option2", "option3"))), - // 5. Titled multi-select using items.anyOf with - // const/title - "titledMulti", - Map.of("type", "array", "items", - Map.of("anyOf", - List.of(Map.of("const", "value1", "title", "First Choice"), - Map.of("const", "value2", "title", "Second Choice"), - Map.of("const", "value3", "title", "Third Choice"))))), + TypeRef> mapType = new TypeRef<>() { + }; + var mapper = McpJsonDefaults.getMapper(); + + // 1. Untitled single-select + var untitledSingle = UntitledSingleSelectEnumSchema.builder() + .enumValues("option1", "option2", "option3") + .build(); + // 2. Titled single-select using oneOf with const/title + var titledSingle = TitledSingleSelectEnumSchema.builder() + .oneOf(EnumSchemaOption.builder("value1", "First Option").build(), + EnumSchemaOption.builder("value2", "Second Option").build(), + EnumSchemaOption.builder("value3", "Third Option").build()) + .build(); + // 3. Legacy titled using enumNames (deprecated) + var legacyEnum = LegacyTitledEnumSchema.builder() + .enumValues("opt1", "opt2", "opt3") + .enumNames("Option One", "Option Two", "Option Three") + .build(); + // 4. Untitled multi-select + var untitledMulti = UntitledMultiSelectEnumSchema.builder( + UntitledMultiSelectItems.builder().enumValues("option1", "option2", "option3").build()) + .build(); + // 5. Titled multi-select using items.anyOf with const/title + var titledMulti = TitledMultiSelectEnumSchema + .builder(TitledMultiSelectItems.builder() + .anyOf(EnumSchemaOption.builder("value1", "First Choice").build(), + EnumSchemaOption.builder("value2", "Second Choice").build(), + EnumSchemaOption.builder("value3", "Third Choice").build()) + .build()) + .build(); + + Map requestedSchema = Map.of("type", "object", "properties", + Map.of("untitledSingle", mapper.convertValue(untitledSingle, mapType), "titledSingle", + mapper.convertValue(titledSingle, mapType), "legacyEnum", + mapper.convertValue(legacyEnum, mapType), "untitledMulti", + mapper.convertValue(untitledMulti, mapType), "titledMulti", + mapper.convertValue(titledMulti, mapType)), "required", List.of("untitledSingle", "titledSingle", "legacyEnum", "untitledMulti", "titledMulti")); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 1366a5efd..ab125fc18 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -3888,6 +3889,636 @@ public CreateMessageResult build() { } // Elicitation + + /** + * An option in a titled enum schema, with a machine-readable value and a + * human-readable display label. + * + * @param constValue The machine-readable value of the option + * @param title The human-readable display label + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record EnumSchemaOption( // @formatter:off + @JsonProperty("const") String constValue, + @JsonProperty("title") String title) { // @formatter:on + + public static Builder builder(String constValue, String title) { + return new Builder(constValue, title); + } + + public static class Builder { + + private final String constValue; + + private final String title; + + private Builder(String constValue, String title) { + Assert.notNull(constValue, "constValue must not be null"); + Assert.notNull(title, "title must not be null"); + this.constValue = constValue; + this.title = title; + } + + public EnumSchemaOption build() { + return new EnumSchemaOption(constValue, title); + } + + } + } + + /** + * Legacy enum schema with optional display names via the non-standard + * {@code enumNames} property. Use {@link TitledSingleSelectEnumSchema} instead. + * + * @param title Optional title for the enum field + * @param description Optional description for the enum field + * @param enumValues Array of enum values to choose from + * @param enumNames Optional display names for enum values (non-standard per JSON + * Schema 2020-12) + * @param defaultValue Optional default value + * @deprecated Use {@link TitledSingleSelectEnumSchema} instead + */ + @Deprecated + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record LegacyTitledEnumSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("enum") List enumValues, + @JsonProperty("enumNames") List enumNames, + @JsonProperty("default") String defaultValue) { // @formatter:on + + public LegacyTitledEnumSchema { + Assert.notNull(enumValues, "enum must not be null"); + } + + @JsonProperty("type") + public String type() { + return "string"; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String title; + + private String description; + + private List enumValues; + + private List enumNames; + + private String defaultValue; + + private Builder() { + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder enumValues(List enumValues) { + Assert.notNull(enumValues, "enum must not be null"); + this.enumValues = new ArrayList<>(enumValues); + return this; + } + + public Builder enumValues(String... enumValues) { + Assert.notNull(enumValues, "enum must not be null"); + this.enumValues = new ArrayList<>(Arrays.asList(enumValues)); + return this; + } + + public Builder enumValue(String enumValue) { + if (this.enumValues == null) { + this.enumValues = new ArrayList<>(); + } + this.enumValues.add(enumValue); + return this; + } + + public Builder enumNames(List enumNames) { + this.enumNames = enumNames == null ? null : new ArrayList<>(enumNames); + return this; + } + + public Builder enumNames(String... enumNames) { + this.enumNames = enumNames == null ? null : new ArrayList<>(Arrays.asList(enumNames)); + return this; + } + + public Builder enumName(String enumName) { + if (this.enumNames == null) { + this.enumNames = new ArrayList<>(); + } + this.enumNames.add(enumName); + return this; + } + + public Builder defaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public LegacyTitledEnumSchema build() { + Assert.notEmpty(enumValues, "enum must not be empty"); + return new LegacyTitledEnumSchema(title, description, enumValues, enumNames, defaultValue); + } + + } + } + + /** + * Schema for single-selection enumeration without display titles for options. + * + * @param title Optional title for the enum field + * @param description Optional description for the enum field + * @param enumValues Array of enum values to choose from + * @param defaultValue Optional default value + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record UntitledSingleSelectEnumSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("enum") List enumValues, + @JsonProperty("default") String defaultValue) { // @formatter:on + + public UntitledSingleSelectEnumSchema { + Assert.notNull(enumValues, "enum must not be null"); + } + + @JsonProperty("type") + public String type() { + return "string"; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String title; + + private String description; + + private List enumValues; + + private String defaultValue; + + private Builder() { + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder enumValues(List enumValues) { + Assert.notNull(enumValues, "enum must not be null"); + this.enumValues = new ArrayList<>(enumValues); + return this; + } + + public Builder enumValues(String... enumValues) { + Assert.notNull(enumValues, "enum must not be null"); + this.enumValues = new ArrayList<>(Arrays.asList(enumValues)); + return this; + } + + public Builder enumValue(String enumValue) { + if (this.enumValues == null) { + this.enumValues = new ArrayList<>(); + } + this.enumValues.add(enumValue); + return this; + } + + public Builder defaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public UntitledSingleSelectEnumSchema build() { + Assert.notEmpty(enumValues, "enum must not be empty"); + return new UntitledSingleSelectEnumSchema(title, description, enumValues, defaultValue); + } + + } + } + + /** + * Schema for single-selection enumeration with display titles for each option. + * + * @param title Optional title for the enum field + * @param description Optional description for the enum field + * @param oneOf Array of enum options, each with a machine-readable value and a + * human-readable display label + * @param defaultValue Optional default value + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record TitledSingleSelectEnumSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("oneOf") List oneOf, + @JsonProperty("default") String defaultValue) { // @formatter:on + + public TitledSingleSelectEnumSchema { + Assert.notEmpty(oneOf, "oneOf must not be empty"); + } + + @JsonProperty("type") + public String type() { + return "string"; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String title; + + private String description; + + private List oneOf; + + private String defaultValue; + + private Builder() { + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder oneOf(List oneOf) { + Assert.notNull(oneOf, "oneOf must not be null"); + this.oneOf = new ArrayList<>(oneOf); + return this; + } + + public Builder oneOf(EnumSchemaOption... oneOf) { + Assert.notNull(oneOf, "oneOf must not be null"); + this.oneOf = new ArrayList<>(Arrays.asList(oneOf)); + return this; + } + + public Builder addOneOf(EnumSchemaOption option) { + if (this.oneOf == null) { + this.oneOf = new ArrayList<>(); + } + this.oneOf.add(option); + return this; + } + + public Builder defaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public TitledSingleSelectEnumSchema build() { + Assert.notEmpty(oneOf, "oneOf must not be empty"); + return new TitledSingleSelectEnumSchema(title, description, oneOf, defaultValue); + } + + } + } + + /** + * The items schema for {@link UntitledMultiSelectEnumSchema}, describing the allowed + * enum values. + * + * @param enumValues Array of enum values to choose from + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record UntitledMultiSelectItems( // @formatter:off + @JsonProperty("enum") List enumValues) { // @formatter:on + + public UntitledMultiSelectItems { + Assert.notNull(enumValues, "enum must not be null"); + } + + @JsonProperty("type") + public String type() { + return "string"; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private List enumValues; + + private Builder() { + } + + public Builder enumValues(List enumValues) { + Assert.notNull(enumValues, "enum must not be null"); + this.enumValues = new ArrayList<>(enumValues); + return this; + } + + public Builder enumValues(String... enumValues) { + Assert.notNull(enumValues, "enum must not be null"); + this.enumValues = new ArrayList<>(Arrays.asList(enumValues)); + return this; + } + + public Builder enumValue(String enumValue) { + if (this.enumValues == null) { + this.enumValues = new ArrayList<>(); + } + this.enumValues.add(enumValue); + return this; + } + + public UntitledMultiSelectItems build() { + Assert.notEmpty(enumValues, "enum must not be empty"); + return new UntitledMultiSelectItems(enumValues); + } + + } + } + + /** + * Schema for multiple-selection enumeration without display titles for options. + * + * @param title Optional title for the enum field + * @param description Optional description for the enum field + * @param items Schema for the array items, containing the list of enum values + * @param minItems Optional minimum number of items to select + * @param maxItems Optional maximum number of items to select + * @param defaultValue Optional default selected values + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record UntitledMultiSelectEnumSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("items") UntitledMultiSelectItems items, + @JsonProperty("minItems") Integer minItems, + @JsonProperty("maxItems") Integer maxItems, + @JsonProperty("default") List defaultValue) { // @formatter:on + + public UntitledMultiSelectEnumSchema { + Assert.notNull(items, "items must not be null"); + } + + @JsonProperty("type") + public String type() { + return "array"; + } + + public static Builder builder(UntitledMultiSelectItems items) { + return new Builder(items); + } + + public static class Builder { + + private String title; + + private String description; + + private UntitledMultiSelectItems items; + + private Integer minItems; + + private Integer maxItems; + + private List defaultValue; + + private Builder(UntitledMultiSelectItems items) { + Assert.notNull(items, "items must not be null"); + this.items = items; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder items(UntitledMultiSelectItems items) { + Assert.notNull(items, "items must not be null"); + this.items = items; + return this; + } + + public Builder minItems(Integer minItems) { + this.minItems = minItems; + return this; + } + + public Builder maxItems(Integer maxItems) { + this.maxItems = maxItems; + return this; + } + + public Builder defaultValue(List defaultValue) { + this.defaultValue = defaultValue == null ? null : new ArrayList<>(defaultValue); + return this; + } + + public Builder addDefaultValue(String defaultValue) { + if (this.defaultValue == null) { + this.defaultValue = new ArrayList<>(); + } + this.defaultValue.add(defaultValue); + return this; + } + + public UntitledMultiSelectEnumSchema build() { + return new UntitledMultiSelectEnumSchema(title, description, items, minItems, maxItems, defaultValue); + } + + } + } + + /** + * The items schema for {@link TitledMultiSelectEnumSchema}, describing the allowed + * enum options with display labels. + * + * @param anyOf Array of enum options, each with a machine-readable value and a + * human-readable display label + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record TitledMultiSelectItems( // @formatter:off + @JsonProperty("anyOf") List anyOf) { // @formatter:on + + public TitledMultiSelectItems { + Assert.notNull(anyOf, "anyOf must not be null"); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private List anyOf; + + private Builder() { + } + + public Builder anyOf(List anyOf) { + Assert.notNull(anyOf, "anyOf must not be null"); + this.anyOf = new ArrayList<>(anyOf); + return this; + } + + public Builder anyOf(EnumSchemaOption... anyOf) { + Assert.notNull(anyOf, "anyOf must not be null"); + this.anyOf = new ArrayList<>(Arrays.asList(anyOf)); + return this; + } + + public Builder addAnyOf(EnumSchemaOption option) { + if (this.anyOf == null) { + this.anyOf = new ArrayList<>(); + } + this.anyOf.add(option); + return this; + } + + public TitledMultiSelectItems build() { + Assert.notEmpty(anyOf, "anyOf must not be empty"); + return new TitledMultiSelectItems(anyOf); + } + + } + } + + /** + * Schema for multiple-selection enumeration with display titles for each option. + * + * @param title Optional title for the enum field + * @param description Optional description for the enum field + * @param items Schema for the array items, containing the list of titled enum options + * @param minItems Optional minimum number of items to select + * @param maxItems Optional maximum number of items to select + * @param defaultValue Optional default selected values + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record TitledMultiSelectEnumSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("items") TitledMultiSelectItems items, + @JsonProperty("minItems") Integer minItems, + @JsonProperty("maxItems") Integer maxItems, + @JsonProperty("default") List defaultValue) { // @formatter:on + + public TitledMultiSelectEnumSchema { + Assert.notNull(items, "items must not be null"); + } + + @JsonProperty("type") + public String type() { + return "array"; + } + + public static Builder builder(TitledMultiSelectItems items) { + return new Builder(items); + } + + public static class Builder { + + private String title; + + private String description; + + private TitledMultiSelectItems items; + + private Integer minItems; + + private Integer maxItems; + + private List defaultValue; + + private Builder(TitledMultiSelectItems items) { + Assert.notNull(items, "items must not be null"); + this.items = items; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder items(TitledMultiSelectItems items) { + Assert.notNull(items, "items must not be null"); + this.items = items; + return this; + } + + public Builder minItems(Integer minItems) { + this.minItems = minItems; + return this; + } + + public Builder maxItems(Integer maxItems) { + this.maxItems = maxItems; + return this; + } + + public Builder defaultValue(List defaultValue) { + this.defaultValue = defaultValue == null ? null : new ArrayList<>(defaultValue); + return this; + } + + public Builder addDefaultValue(String defaultValue) { + if (this.defaultValue == null) { + this.defaultValue = new ArrayList<>(); + } + this.defaultValue.add(defaultValue); + return this; + } + + public TitledMultiSelectEnumSchema build() { + return new TitledMultiSelectEnumSchema(title, description, items, minItems, maxItems, defaultValue); + } + + } + } + /** * A request from the server to elicit additional information from the user, either * through the client or out-of-band. @@ -3930,13 +4561,47 @@ static ElicitFormRequest.Builder builder(String message, Map req /** * A request from the server to elicit additional information from the user via the * client, using {@code form} mode. + *

+ * The requested schema is flexible, but for standard schemas, consider using one the + * following types: + *

    + *
  • {@link LegacyTitledEnumSchema} + *
  • {@link TitledSingleSelectEnumSchema} + *
  • {@link TitledMultiSelectEnumSchema} + *
  • {@link UntitledSingleSelectEnumSchema} + *
  • {@link UntitledMultiSelectEnumSchema} + *
+ * + * These can be used with a JSON mapper: + * + *
+	 * var mapper = McpJsonDefaults.getMapper();
+	 * TypeRef<Map<String, Object>> mapType = new TypeRef<>() { };
+	 * var first = UntitledSingleSelectEnumSchema.builder()
+	 *           .enumValues("option1", "option2", "option3")
+	 *           .build();
+	 * var second = TitledMultiSelectEnumSchema
+	 *           .builder(TitledMultiSelectItems.builder()
+	 *             .anyOf(
+	 *               EnumSchemaOption.builder("value1", "First Choice").build(),
+	 *               EnumSchemaOption.builder("value2", "Second Choice").build(),
+	 *               EnumSchemaOption.builder("value3", "Third Choice").build()
+	 *             ).build()).build();
+	 * Map<String, Object> requestedSchema = Map.of(
+	 *     "type", "object",
+	 *     "properties", Map.of(
+	 *         "first-thing", mapper.convertValue(first, mapType),
+	 *         "second-thing", mapper.convertValue(second, mapType)),
+	 *     "required", List.of("first-thing", "second-thing"));
+	 * 
* * @param message The message to present to the user * @param requestedSchema A restricted subset of JSON Schema. Only top-level * properties are allowed, without nesting. Per SEP-1613, the dialect defaults to JSON * Schema 2020-12 ({@link #JSON_SCHEMA_DIALECT_2020_12}) when no explicit * {@code $schema} entry is present. To declare a different dialect, include a - * {@code "$schema"} key in the map. + * {@code "$schema"} key in the map. For type-safety in the schemas, use one of the + * supported schema types. * @param meta See specification for notes on _meta usage *

* Note: {@code message} and {@code requestedSchema} are required by the MCP diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 6ac076559..4229ed926 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -1726,6 +1726,319 @@ void testElicitRequestToleratesUnknownFields() throws Exception { assertThat(request.message()).isEqualTo("hello"); } + // Enum Schema Tests + + @Test + void testUntitledSingleSelectEnumSchemaSerialization() throws Exception { + var schema = new McpSchema.UntitledSingleSelectEnumSchema(null, "Choose a color", + List.of("red", "green", "blue"), null); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + {"type":"string","description":"Choose a color","enum":["red","green","blue"]}""")); + } + + @Test + void testUntitledSingleSelectEnumSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + {"type":"string","description":"Pick one","enum":["a","b","c"],"default":"a"}""", + McpSchema.UntitledSingleSelectEnumSchema.class); + + assertThat(schema.type()).isEqualTo("string"); + assertThat(schema.description()).isEqualTo("Pick one"); + assertThat(schema.enumValues()).containsExactly("a", "b", "c"); + assertThat(schema.defaultValue()).isEqualTo("a"); + } + + @Test + void testTitledSingleSelectEnumSchemaSerialization() throws Exception { + var schema = new McpSchema.TitledSingleSelectEnumSchema("Priority", "Select a priority", + List.of(new McpSchema.EnumSchemaOption("low", "Low"), new McpSchema.EnumSchemaOption("high", "High")), + null); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "string", + "title": "Priority", + "description": "Select a priority", + "oneOf": [ + {"const": "low", "title": "Low"}, + {"const": "high", "title": "High"} + ] + }""")); + } + + @Test + void testTitledSingleSelectEnumSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "string", + "title": "Color", + "oneOf": [ + {"const": "red", "title": "Red"}, + {"const": "blue", "title": "Blue"} + ], + "default": "red" + }""", McpSchema.TitledSingleSelectEnumSchema.class); + + assertThat(schema.type()).isEqualTo("string"); + assertThat(schema.title()).isEqualTo("Color"); + assertThat(schema.oneOf()).hasSize(2); + assertThat(schema.oneOf().get(0).constValue()).isEqualTo("red"); + assertThat(schema.oneOf().get(0).title()).isEqualTo("Red"); + assertThat(schema.defaultValue()).isEqualTo("red"); + } + + @Test + @SuppressWarnings("deprecation") + void testLegacyTitledEnumSchemaSerialization() throws Exception { + var schema = new McpSchema.LegacyTitledEnumSchema(null, null, List.of("a", "b"), + List.of("Option A", "Option B"), null); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + {"type":"string","enum":["a","b"],"enumNames":["Option A","Option B"]}""")); + } + + @Test + @SuppressWarnings("deprecation") + void testLegacyTitledEnumSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + {"type":"string","enum":["x","y"],"enumNames":["Ex","Why"]}""", McpSchema.LegacyTitledEnumSchema.class); + + assertThat(schema.type()).isEqualTo("string"); + assertThat(schema.enumValues()).containsExactly("x", "y"); + assertThat(schema.enumNames()).containsExactly("Ex", "Why"); + } + + @Test + void testUntitledMultiSelectEnumSchemaSerialization() throws Exception { + var items = new McpSchema.UntitledMultiSelectItems(List.of("js", "java", "python")); + var schema = new McpSchema.UntitledMultiSelectEnumSchema("Languages", null, items, 1, 3, null); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "array", + "title": "Languages", + "items": {"type": "string", "enum": ["js", "java", "python"]}, + "minItems": 1, + "maxItems": 3 + }""")); + } + + @Test + void testUntitledMultiSelectEnumSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "array", + "items": {"type": "string", "enum": ["a", "b", "c"]}, + "default": ["a"] + }""", McpSchema.UntitledMultiSelectEnumSchema.class); + + assertThat(schema.type()).isEqualTo("array"); + assertThat(schema.items().enumValues()).containsExactly("a", "b", "c"); + assertThat(schema.defaultValue()).containsExactly("a"); + } + + @Test + void testTitledMultiSelectEnumSchemaSerialization() throws Exception { + var options = List.of(new McpSchema.EnumSchemaOption("js", "JavaScript"), + new McpSchema.EnumSchemaOption("java", "Java")); + var items = new McpSchema.TitledMultiSelectItems(options); + var schema = new McpSchema.TitledMultiSelectEnumSchema("Languages", "Pick languages", items, null, null, null); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "array", + "title": "Languages", + "description": "Pick languages", + "items": { + "anyOf": [ + {"const": "js", "title": "JavaScript"}, + {"const": "java", "title": "Java"} + ] + } + }""")); + } + + @Test + void testTitledMultiSelectEnumSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "array", + "title": "Flavors", + "items": { + "anyOf": [ + {"const": "vanilla", "title": "Vanilla"}, + {"const": "chocolate", "title": "Chocolate"} + ] + }, + "default": ["vanilla"] + }""", McpSchema.TitledMultiSelectEnumSchema.class); + + assertThat(schema.type()).isEqualTo("array"); + assertThat(schema.title()).isEqualTo("Flavors"); + assertThat(schema.items().anyOf()).hasSize(2); + assertThat(schema.items().anyOf().get(0).constValue()).isEqualTo("vanilla"); + assertThat(schema.items().anyOf().get(0).title()).isEqualTo("Vanilla"); + assertThat(schema.defaultValue()).containsExactly("vanilla"); + } + + @Test + void testUntitledSingleSelectEnumSchemaBuilderRequiresEnumValues() { + assertThatThrownBy(() -> McpSchema.UntitledSingleSelectEnumSchema.builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("enum must not be empty"); + } + + @Test + void testUntitledSingleSelectEnumSchemaBuilderRejectsEmptyEnumValues() { + assertThatThrownBy(() -> McpSchema.UntitledSingleSelectEnumSchema.builder().enumValues(List.of()).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("enum must not be empty"); + } + + @Test + void testTitledSingleSelectEnumSchemaBuilderRequiresOneOf() { + assertThatThrownBy(() -> McpSchema.TitledSingleSelectEnumSchema.builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("oneOf must not be empty"); + } + + @Test + void testTitledSingleSelectEnumSchemaBuilderRejectsEmptyOneOf() { + assertThatThrownBy(() -> McpSchema.TitledSingleSelectEnumSchema.builder().oneOf(List.of()).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("oneOf must not be empty"); + } + + @Test + @SuppressWarnings("deprecation") + void testLegacyTitledEnumSchemaBuilderRequiresEnumValues() { + assertThatThrownBy(() -> McpSchema.LegacyTitledEnumSchema.builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("enum must not be empty"); + } + + @Test + @SuppressWarnings("deprecation") + void testLegacyTitledEnumSchemaBuilderRejectsEmptyEnumValues() { + assertThatThrownBy(() -> McpSchema.LegacyTitledEnumSchema.builder().enumValues(List.of()).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("enum must not be empty"); + } + + @Test + void testUntitledMultiSelectItemsBuilderRequiresEnumValues() { + assertThatThrownBy(() -> McpSchema.UntitledMultiSelectItems.builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("enum must not be empty"); + } + + @Test + void testUntitledMultiSelectItemsBuilderRejectsEmptyEnumValues() { + assertThatThrownBy(() -> McpSchema.UntitledMultiSelectItems.builder().enumValues(List.of()).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("enum must not be empty"); + } + + @Test + void testTitledMultiSelectItemsBuilderRequiresAnyOf() { + assertThatThrownBy(() -> McpSchema.TitledMultiSelectItems.builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("anyOf must not be empty"); + } + + @Test + void testTitledMultiSelectItemsBuilderRejectsEmptyAnyOf() { + assertThatThrownBy(() -> McpSchema.TitledMultiSelectItems.builder().anyOf(List.of()).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("anyOf must not be empty"); + } + + @Test + void testUntitledSingleSelectEnumSchemaBuilderSingularAdd() { + var schema = McpSchema.UntitledSingleSelectEnumSchema.builder().enumValues("a", "b").enumValue("c").build(); + + assertThat(schema.enumValues()).containsExactly("a", "b", "c"); + } + + @Test + void testUntitledSingleSelectEnumSchemaBuilderOptionalFields() { + var schema = McpSchema.UntitledSingleSelectEnumSchema.builder() + .title("Color") + .description("Pick a color") + .enumValues("red", "blue") + .defaultValue("red") + .build(); + + assertThat(schema.title()).isEqualTo("Color"); + assertThat(schema.description()).isEqualTo("Pick a color"); + assertThat(schema.defaultValue()).isEqualTo("red"); + } + + @Test + void testTitledSingleSelectEnumSchemaBuilderSingularAdd() { + var opt1 = McpSchema.EnumSchemaOption.builder("v1", "Option 1").build(); + var opt2 = McpSchema.EnumSchemaOption.builder("v2", "Option 2").build(); + var schema = McpSchema.TitledSingleSelectEnumSchema.builder().oneOf(opt1).addOneOf(opt2).build(); + + assertThat(schema.oneOf()).hasSize(2); + assertThat(schema.oneOf().get(1).constValue()).isEqualTo("v2"); + } + + @Test + @SuppressWarnings("deprecation") + void testLegacyTitledEnumSchemaBuilderSingularAdds() { + var schema = McpSchema.LegacyTitledEnumSchema.builder() + .enumValues("a", "b") + .enumValue("c") + .enumNames("Alpha", "Beta") + .enumName("Gamma") + .build(); + + assertThat(schema.enumValues()).containsExactly("a", "b", "c"); + assertThat(schema.enumNames()).containsExactly("Alpha", "Beta", "Gamma"); + } + + @Test + void testUntitledMultiSelectItemsBuilderSingularAdd() { + var items = McpSchema.UntitledMultiSelectItems.builder().enumValues("x", "y").enumValue("z").build(); + + assertThat(items.enumValues()).containsExactly("x", "y", "z"); + } + + @Test + void testTitledMultiSelectItemsBuilderSingularAdd() { + var opt1 = McpSchema.EnumSchemaOption.builder("v1", "First").build(); + var opt2 = McpSchema.EnumSchemaOption.builder("v2", "Second").build(); + var items = McpSchema.TitledMultiSelectItems.builder().anyOf(opt1).addAnyOf(opt2).build(); + + assertThat(items.anyOf()).hasSize(2); + assertThat(items.anyOf().get(1).constValue()).isEqualTo("v2"); + } + + @Test + void testUntitledMultiSelectEnumSchemaBuilderOptionalFields() { + var items = McpSchema.UntitledMultiSelectItems.builder().enumValues("a", "b").build(); + var schema = McpSchema.UntitledMultiSelectEnumSchema.builder(items) + .title("Tags") + .description("Select tags") + .minItems(1) + .maxItems(2) + .defaultValue(List.of("a")) + .addDefaultValue("b") + .build(); + + assertThat(schema.title()).isEqualTo("Tags"); + assertThat(schema.minItems()).isEqualTo(1); + assertThat(schema.maxItems()).isEqualTo(2); + assertThat(schema.defaultValue()).containsExactly("a", "b"); + } + // Pagination Tests @Test From c6b79640e8955b3f38dd66a3e72903d918575be7 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 11 Jun 2026 09:47:45 +0200 Subject: [PATCH 2/4] Add NumberSchema, StringSchema and BooleanSchema for form-based elicitation Signed-off-by: Daniel Garnier-Moiroux --- .../modelcontextprotocol/spec/McpSchema.java | 242 ++++++++++++++- .../spec/McpSchemaTests.java | 289 ++++++++++++++++++ 2 files changed, 524 insertions(+), 7 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index ab125fc18..b5567b364 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -4519,6 +4519,233 @@ public TitledMultiSelectEnumSchema build() { } } + /** + * Schema for a boolean field in a form-based elicitation request. + * + * @param title Optional title for the boolean field + * @param description Optional description for the boolean field + * @param defaultValue Optional default value + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record BooleanSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("default") Boolean defaultValue) { // @formatter:on + + @JsonProperty("type") + public String type() { + return "boolean"; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String title; + + private String description; + + private Boolean defaultValue; + + private Builder() { + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder defaultValue(Boolean defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public BooleanSchema build() { + return new BooleanSchema(title, description, defaultValue); + } + + } + } + + /** + * Schema for a numeric field in a form-based elicitation request, supporting both + * {@code "number"} (floating-point) and {@code "integer"} types. + * + * @param title Optional title for the numeric field + * @param description Optional description for the numeric field + * @param type The JSON Schema type, either {@code "number"} or {@code "integer"}; + * defaults to {@code "number"} in the builder + * @param minimum Optional minimum value (inclusive) + * @param maximum Optional maximum value (inclusive) + * @param defaultValue Optional default value + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record NumberSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("type") String type, + @JsonProperty("minimum") Number minimum, + @JsonProperty("maximum") Number maximum, + @JsonProperty("default") Number defaultValue) { // @formatter:on + + public NumberSchema { + Assert.notNull(type, "type must not be null"); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String title; + + private String description; + + private String type = "number"; + + private Number minimum; + + private Number maximum; + + private Number defaultValue; + + private Builder() { + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder integer() { + this.type = "integer"; + return this; + } + + public Builder minimum(Number minimum) { + this.minimum = minimum; + return this; + } + + public Builder maximum(Number maximum) { + this.maximum = maximum; + return this; + } + + public Builder defaultValue(Number defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public NumberSchema build() { + return new NumberSchema(title, description, type, minimum, maximum, defaultValue); + } + + } + } + + /** + * Schema for a text input field in a form-based elicitation request. + * + * @param title Optional title for the text field + * @param description Optional description for the text field + * @param minLength Optional minimum string length + * @param maxLength Optional maximum string length + * @param format Optional format hint (e.g. {@code "email"}, {@code "uri"}) + * @param defaultValue Optional default value + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record StringSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("minLength") Integer minLength, + @JsonProperty("maxLength") Integer maxLength, + @JsonProperty("format") String format, + @JsonProperty("default") String defaultValue) { // @formatter:on + + @JsonProperty("type") + public String type() { + return "string"; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String title; + + private String description; + + private Integer minLength; + + private Integer maxLength; + + private String format; + + private String defaultValue; + + private Builder() { + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder minLength(Integer minLength) { + this.minLength = minLength; + return this; + } + + public Builder maxLength(Integer maxLength) { + this.maxLength = maxLength; + return this; + } + + public Builder format(String format) { + this.format = format; + return this; + } + + public Builder defaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public StringSchema build() { + Assert.isTrue( + format == null || format.equals("uri") || format.equals("email") || format.equals("date") + || format.equals("date-time"), + "format must be one of: null, \"uri\", \"email\", \"date\", \"date-time\""); + return new StringSchema(title, description, minLength, maxLength, format, defaultValue); + } + + } + } + /** * A request from the server to elicit additional information from the user, either * through the client or out-of-band. @@ -4565,6 +4792,9 @@ static ElicitFormRequest.Builder builder(String message, Map req * The requested schema is flexible, but for standard schemas, consider using one the * following types: *

    + *
  • {@link BooleanSchema} + *
  • {@link NumberSchema} + *
  • {@link StringSchema} *
  • {@link LegacyTitledEnumSchema} *
  • {@link TitledSingleSelectEnumSchema} *
  • {@link TitledMultiSelectEnumSchema} @@ -4580,13 +4810,11 @@ static ElicitFormRequest.Builder builder(String message, Map req * var first = UntitledSingleSelectEnumSchema.builder() * .enumValues("option1", "option2", "option3") * .build(); - * var second = TitledMultiSelectEnumSchema - * .builder(TitledMultiSelectItems.builder() - * .anyOf( - * EnumSchemaOption.builder("value1", "First Choice").build(), - * EnumSchemaOption.builder("value2", "Second Choice").build(), - * EnumSchemaOption.builder("value3", "Third Choice").build() - * ).build()).build(); + * var second = BooleanSchema + * .builder() + * .title("Say yes") + * .description("By selecting this, you say yes to the thing") + * .build(); * Map<String, Object> requestedSchema = Map.of( * "type", "object", * "properties", Map.of( diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 4229ed926..9f7c737e5 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -16,6 +16,9 @@ import net.javacrumbs.jsonunit.core.Option; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; @@ -2039,6 +2042,292 @@ void testUntitledMultiSelectEnumSchemaBuilderOptionalFields() { assertThat(schema.defaultValue()).containsExactly("a", "b"); } + // Primitive Elicitation Schema Tests (BooleanSchema, NumberSchema, StringSchema) + + @Test + void testBooleanSchemaSerialization() throws Exception { + var schema = new McpSchema.BooleanSchema(null, "Enable feature", true); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "boolean", + "description": "Enable feature", + "default": true + }""")); + } + + @Test + void testBooleanSchemaSerializationOmitsNullFields() throws Exception { + var schema = new McpSchema.BooleanSchema(null, null, null); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "boolean" + }""")); + } + + @Test + void testBooleanSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "boolean", + "title": "Subscribe", + "description": "Opt in", + "default": false + }""", McpSchema.BooleanSchema.class); + + assertThat(schema.type()).isEqualTo("boolean"); + assertThat(schema.title()).isEqualTo("Subscribe"); + assertThat(schema.description()).isEqualTo("Opt in"); + assertThat(schema.defaultValue()).isEqualTo(false); + } + + @Test + void testBooleanSchemaBuilderAllFields() { + var schema = McpSchema.BooleanSchema.builder() + .title("Send notifications") + .description("Receive email updates") + .defaultValue(true) + .build(); + + assertThat(schema.title()).isEqualTo("Send notifications"); + assertThat(schema.description()).isEqualTo("Receive email updates"); + assertThat(schema.defaultValue()).isTrue(); + assertThat(schema.type()).isEqualTo("boolean"); + } + + @Test + void testBooleanSchemaToleratesUnknownFields() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "boolean", + "futureField": 42 + }""", McpSchema.BooleanSchema.class); + + assertThat(schema.type()).isEqualTo("boolean"); + } + + @Test + void testNumberSchemaSerialization() throws Exception { + var schema = new McpSchema.NumberSchema(null, "Enter a score", "number", 0.0, 100.0, 50.0); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "number", + "description": "Enter a score", + "minimum": 0.0, + "maximum": 100.0, + "default": 50.0 + }""")); + } + + @Test + void testNumberSchemaSerializationIntegerType() throws Exception { + var schema = McpSchema.NumberSchema.builder() + .integer() + .description("Enter age") + .minimum(0) + .maximum(150) + .build(); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "integer", + "description": "Enter age", + "minimum": 0, + "maximum": 150 + }""")); + } + + @Test + void testNumberSchemaSerializationOmitsNullFields() throws Exception { + var schema = McpSchema.NumberSchema.builder().build(); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "number" + }""")); + } + + @Test + void testNumberSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "number", + "title": "Score", + "minimum": 0, + "maximum": 10, + "default": 5.5 + }""", McpSchema.NumberSchema.class); + + assertThat(schema.type()).isEqualTo("number"); + assertThat(schema.title()).isEqualTo("Score"); + assertThat(schema.minimum()).isEqualTo(0); + assertThat(schema.maximum()).isEqualTo(10); + assertThat(schema.defaultValue()).isEqualTo(5.5); + } + + @Test + void testNumberSchemaDeserializationIntegerType() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "integer", + "description": "Age", + "minimum": 18 + }""", McpSchema.NumberSchema.class); + + assertThat(schema.type()).isEqualTo("integer"); + assertThat(schema.description()).isEqualTo("Age"); + assertThat(schema.minimum()).isEqualTo(18); + } + + @Test + void testNumberSchemaBuilderDefaultsToNumberType() { + var schema = McpSchema.NumberSchema.builder().build(); + + assertThat(schema.type()).isEqualTo("number"); + } + + @Test + void testNumberSchemaBuilderIntegerType() { + var schema = McpSchema.NumberSchema.builder().integer().build(); + + assertThat(schema.type()).isEqualTo("integer"); + } + + @Test + void testNumberSchemaBuilderAllFields() { + var schema = McpSchema.NumberSchema.builder() + .title("Price") + .description("Item price") + .minimum(0.01) + .maximum(9999.99) + .defaultValue(19.99) + .build(); + + assertThat(schema.title()).isEqualTo("Price"); + assertThat(schema.description()).isEqualTo("Item price"); + assertThat(schema.minimum()).isEqualTo(0.01); + assertThat(schema.maximum()).isEqualTo(9999.99); + assertThat(schema.defaultValue()).isEqualTo(19.99); + } + + @Test + void testNumberSchemaToleratesUnknownFields() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "number", + "futureField": "ignored" + }""", McpSchema.NumberSchema.class); + + assertThat(schema.type()).isEqualTo("number"); + } + + @Test + void testStringSchemaSerialization() throws Exception { + var schema = new McpSchema.StringSchema("Email", "Your email address", 5, 255, "email", "user@example.com"); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "string", + "title": "Email", + "description": "Your email address", + "minLength": 5, + "maxLength": 255, + "format": "email", + "default": "user@example.com" + }""")); + } + + @Test + void testStringSchemaSerializationOmitsNullFields() throws Exception { + var schema = new McpSchema.StringSchema(null, null, null, null, null, null); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "string" + }""")); + } + + @Test + void testStringSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "string", + "title": "Name", + "description": "Your name", + "minLength": 1, + "maxLength": 100, + "default": "Alice" + }""", McpSchema.StringSchema.class); + + assertThat(schema.type()).isEqualTo("string"); + assertThat(schema.title()).isEqualTo("Name"); + assertThat(schema.description()).isEqualTo("Your name"); + assertThat(schema.minLength()).isEqualTo(1); + assertThat(schema.maxLength()).isEqualTo(100); + assertThat(schema.defaultValue()).isEqualTo("Alice"); + } + + @Test + void testStringSchemaBuilderAllFields() { + var schema = McpSchema.StringSchema.builder() + .title("Website") + .description("Your website URL") + .minLength(10) + .maxLength(200) + .format("uri") + .defaultValue("https://example.com") + .build(); + + assertThat(schema.title()).isEqualTo("Website"); + assertThat(schema.description()).isEqualTo("Your website URL"); + assertThat(schema.minLength()).isEqualTo(10); + assertThat(schema.maxLength()).isEqualTo(200); + assertThat(schema.format()).isEqualTo("uri"); + assertThat(schema.defaultValue()).isEqualTo("https://example.com"); + assertThat(schema.type()).isEqualTo("string"); + } + + @Test + void testStringSchemaToleratesUnknownFields() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "string", + "futureField": "ignored" + }""", McpSchema.StringSchema.class); + + assertThat(schema.type()).isEqualTo("string"); + } + + @ParameterizedTest + @ValueSource(strings = { "uri", "email", "date", "date-time" }) + @NullSource + void testStringSchemaBuilderAcceptsValidFormats(String format) { + var schema = McpSchema.StringSchema.builder().format(format).build(); + assertThat(schema.format()).isEqualTo(format); + } + + @Test + void testStringSchemaBuilderAcceptsNullFormat() { + var schema = McpSchema.StringSchema.builder().build(); + assertThat(schema.format()).isNull(); + } + + @Test + void testStringSchemaBuilderRejectsInvalidFormat() { + assertThatThrownBy(() -> McpSchema.StringSchema.builder().format("uuid").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("format must be one of"); + } + // Pagination Tests @Test From 87ef94ea638251e8a512aaa9afc5278efd6bd5fb Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 11 Jun 2026 10:08:24 +0200 Subject: [PATCH 3/4] Address PR comments --- .../server/ConformanceServlet.java | 12 +- .../modelcontextprotocol/spec/McpSchema.java | 152 ++++++------------ .../spec/McpSchemaTests.java | 74 +++++---- 3 files changed, 100 insertions(+), 138 deletions(-) diff --git a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java index 016fd8362..77b7322f7 100644 --- a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java +++ b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java @@ -456,9 +456,9 @@ private static List createToolSpecs() { .build(); // 2. Titled single-select using oneOf with const/title var titledSingle = TitledSingleSelectEnumSchema.builder() - .oneOf(EnumSchemaOption.builder("value1", "First Option").build(), - EnumSchemaOption.builder("value2", "Second Option").build(), - EnumSchemaOption.builder("value3", "Third Option").build()) + .oneOf(new EnumSchemaOption("value1", "First Option"), + new EnumSchemaOption("value2", "Second Option"), + new EnumSchemaOption("value3", "Third Option")) .build(); // 3. Legacy titled using enumNames (deprecated) var legacyEnum = LegacyTitledEnumSchema.builder() @@ -472,9 +472,9 @@ private static List createToolSpecs() { // 5. Titled multi-select using items.anyOf with const/title var titledMulti = TitledMultiSelectEnumSchema .builder(TitledMultiSelectItems.builder() - .anyOf(EnumSchemaOption.builder("value1", "First Choice").build(), - EnumSchemaOption.builder("value2", "Second Choice").build(), - EnumSchemaOption.builder("value3", "Third Choice").build()) + .anyOf(new EnumSchemaOption("value1", "First Choice"), + new EnumSchemaOption("value2", "Second Choice"), + new EnumSchemaOption("value3", "Third Choice")) .build()) .build(); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index b5567b364..648be8b4b 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -3903,28 +3903,30 @@ public record EnumSchemaOption( // @formatter:off @JsonProperty("const") String constValue, @JsonProperty("title") String title) { // @formatter:on - public static Builder builder(String constValue, String title) { - return new Builder(constValue, title); + public EnumSchemaOption { + Assert.notNull(constValue, "constValue must not be null"); + Assert.notNull(title, "title must not be null"); } - public static class Builder { - - private final String constValue; - - private final String title; - - private Builder(String constValue, String title) { - Assert.notNull(constValue, "constValue must not be null"); - Assert.notNull(title, "title must not be null"); - this.constValue = constValue; - this.title = title; - } - - public EnumSchemaOption build() { - return new EnumSchemaOption(constValue, title); + @JsonCreator + static EnumSchemaOption fromJson(@JsonProperty("const") String constValue, + @JsonProperty("title") String title) { + if (constValue == null || title == null) { + List missing = new ArrayList<>(); + if (constValue == null) { + missing.add("constValue -> ''"); + constValue = ""; + } + if (title == null) { + missing.add("title -> ''"); + title = ""; + } + logger.warn("EnumSchemaOption: missing required fields during deserialization: {}", + String.join(", ", missing)); } - + return new EnumSchemaOption(constValue, title); } + } /** @@ -3950,7 +3952,7 @@ public record LegacyTitledEnumSchema( // @formatter:off @JsonProperty("default") String defaultValue) { // @formatter:on public LegacyTitledEnumSchema { - Assert.notNull(enumValues, "enum must not be null"); + Assert.notNull(enumValues, "enumValues must not be null"); } @JsonProperty("type") @@ -3988,40 +3990,26 @@ public Builder description(String description) { } public Builder enumValues(List enumValues) { - Assert.notNull(enumValues, "enum must not be null"); + Assert.notNull(enumValues, "enumValues must not be null"); this.enumValues = new ArrayList<>(enumValues); return this; } public Builder enumValues(String... enumValues) { - Assert.notNull(enumValues, "enum must not be null"); - this.enumValues = new ArrayList<>(Arrays.asList(enumValues)); - return this; - } - - public Builder enumValue(String enumValue) { - if (this.enumValues == null) { - this.enumValues = new ArrayList<>(); - } - this.enumValues.add(enumValue); + Assert.notNull(enumValues, "enumValues must not be null"); + this.enumValues = Arrays.asList(enumValues); return this; } public Builder enumNames(List enumNames) { - this.enumNames = enumNames == null ? null : new ArrayList<>(enumNames); + Assert.notNull(enumNames, "enumNames must not be null"); + this.enumNames = new ArrayList<>(enumNames); return this; } public Builder enumNames(String... enumNames) { - this.enumNames = enumNames == null ? null : new ArrayList<>(Arrays.asList(enumNames)); - return this; - } - - public Builder enumName(String enumName) { - if (this.enumNames == null) { - this.enumNames = new ArrayList<>(); - } - this.enumNames.add(enumName); + Assert.notNull(enumNames, "enumNames must not be null"); + this.enumNames = Arrays.asList(enumNames); return this; } @@ -4031,7 +4019,7 @@ public Builder defaultValue(String defaultValue) { } public LegacyTitledEnumSchema build() { - Assert.notEmpty(enumValues, "enum must not be empty"); + Assert.notEmpty(enumValues, "enumValues must not be empty"); return new LegacyTitledEnumSchema(title, description, enumValues, enumNames, defaultValue); } @@ -4055,7 +4043,7 @@ public record UntitledSingleSelectEnumSchema( // @formatter:off @JsonProperty("default") String defaultValue) { // @formatter:on public UntitledSingleSelectEnumSchema { - Assert.notNull(enumValues, "enum must not be null"); + Assert.notNull(enumValues, "enumValues must not be null"); } @JsonProperty("type") @@ -4091,22 +4079,14 @@ public Builder description(String description) { } public Builder enumValues(List enumValues) { - Assert.notNull(enumValues, "enum must not be null"); + Assert.notNull(enumValues, "enumValues must not be null"); this.enumValues = new ArrayList<>(enumValues); return this; } public Builder enumValues(String... enumValues) { - Assert.notNull(enumValues, "enum must not be null"); - this.enumValues = new ArrayList<>(Arrays.asList(enumValues)); - return this; - } - - public Builder enumValue(String enumValue) { - if (this.enumValues == null) { - this.enumValues = new ArrayList<>(); - } - this.enumValues.add(enumValue); + Assert.notNull(enumValues, "enumValues must not be null"); + this.enumValues = Arrays.asList(enumValues); return this; } @@ -4116,7 +4096,7 @@ public Builder defaultValue(String defaultValue) { } public UntitledSingleSelectEnumSchema build() { - Assert.notEmpty(enumValues, "enum must not be empty"); + Assert.notEmpty(enumValues, "enumValues must not be empty"); return new UntitledSingleSelectEnumSchema(title, description, enumValues, defaultValue); } @@ -4184,15 +4164,7 @@ public Builder oneOf(List oneOf) { public Builder oneOf(EnumSchemaOption... oneOf) { Assert.notNull(oneOf, "oneOf must not be null"); - this.oneOf = new ArrayList<>(Arrays.asList(oneOf)); - return this; - } - - public Builder addOneOf(EnumSchemaOption option) { - if (this.oneOf == null) { - this.oneOf = new ArrayList<>(); - } - this.oneOf.add(option); + this.oneOf = Arrays.asList(oneOf); return this; } @@ -4221,7 +4193,7 @@ public record UntitledMultiSelectItems( // @formatter:off @JsonProperty("enum") List enumValues) { // @formatter:on public UntitledMultiSelectItems { - Assert.notNull(enumValues, "enum must not be null"); + Assert.notNull(enumValues, "enumValues must not be null"); } @JsonProperty("type") @@ -4241,27 +4213,19 @@ private Builder() { } public Builder enumValues(List enumValues) { - Assert.notNull(enumValues, "enum must not be null"); + Assert.notNull(enumValues, "enumValues must not be null"); this.enumValues = new ArrayList<>(enumValues); return this; } public Builder enumValues(String... enumValues) { - Assert.notNull(enumValues, "enum must not be null"); - this.enumValues = new ArrayList<>(Arrays.asList(enumValues)); - return this; - } - - public Builder enumValue(String enumValue) { - if (this.enumValues == null) { - this.enumValues = new ArrayList<>(); - } - this.enumValues.add(enumValue); + Assert.notNull(enumValues, "enumValues must not be null"); + this.enumValues = Arrays.asList(enumValues); return this; } public UntitledMultiSelectItems build() { - Assert.notEmpty(enumValues, "enum must not be empty"); + Assert.notEmpty(enumValues, "enumValues must not be empty"); return new UntitledMultiSelectItems(enumValues); } @@ -4346,16 +4310,15 @@ public Builder maxItems(Integer maxItems) { return this; } - public Builder defaultValue(List defaultValue) { - this.defaultValue = defaultValue == null ? null : new ArrayList<>(defaultValue); + public Builder defaults(String... defaultValue) { + Assert.notNull(defaultValue, "defaultValue must not be null"); + this.defaultValue = Arrays.asList(defaultValue); return this; } - public Builder addDefaultValue(String defaultValue) { - if (this.defaultValue == null) { - this.defaultValue = new ArrayList<>(); - } - this.defaultValue.add(defaultValue); + public Builder defaults(List defaultValue) { + Assert.notNull(defaultValue, "defaultValue must not be null"); + this.defaultValue = new ArrayList<>(defaultValue); return this; } @@ -4401,15 +4364,7 @@ public Builder anyOf(List anyOf) { public Builder anyOf(EnumSchemaOption... anyOf) { Assert.notNull(anyOf, "anyOf must not be null"); - this.anyOf = new ArrayList<>(Arrays.asList(anyOf)); - return this; - } - - public Builder addAnyOf(EnumSchemaOption option) { - if (this.anyOf == null) { - this.anyOf = new ArrayList<>(); - } - this.anyOf.add(option); + this.anyOf = Arrays.asList(anyOf); return this; } @@ -4499,16 +4454,15 @@ public Builder maxItems(Integer maxItems) { return this; } - public Builder defaultValue(List defaultValue) { - this.defaultValue = defaultValue == null ? null : new ArrayList<>(defaultValue); + public Builder defaults(List defaultValue) { + Assert.notNull(defaultValue, "defaultValue must not be null"); + this.defaultValue = new ArrayList<>(defaultValue); return this; } - public Builder addDefaultValue(String defaultValue) { - if (this.defaultValue == null) { - this.defaultValue = new ArrayList<>(); - } - this.defaultValue.add(defaultValue); + public Builder defaults(String... defaultValue) { + Assert.notNull(defaultValue, "defaultValue must not be null"); + this.defaultValue = Arrays.asList(defaultValue); return this; } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 9f7c737e5..b5e23252d 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -1731,6 +1731,26 @@ void testElicitRequestToleratesUnknownFields() throws Exception { // Enum Schema Tests + @Test + void testEnumSchemaOptionDeserialization() throws Exception { + var option = JSON_MAPPER.readValue(""" + { + "const": "low", + "title": "Low Priority" + }""", McpSchema.EnumSchemaOption.class); + + assertThat(option.constValue()).isEqualTo("low"); + assertThat(option.title()).isEqualTo("Low Priority"); + } + + @Test + void testEnumSchemaOptionDeserializationWithBothFieldsMissing() throws Exception { + var option = JSON_MAPPER.readValue("{}", McpSchema.EnumSchemaOption.class); + + assertThat(option.constValue()).isEqualTo(""); + assertThat(option.title()).isEqualTo(""); + } + @Test void testUntitledSingleSelectEnumSchemaSerialization() throws Exception { var schema = new McpSchema.UntitledSingleSelectEnumSchema(null, "Choose a color", @@ -1894,14 +1914,14 @@ void testTitledMultiSelectEnumSchemaDeserialization() throws Exception { void testUntitledSingleSelectEnumSchemaBuilderRequiresEnumValues() { assertThatThrownBy(() -> McpSchema.UntitledSingleSelectEnumSchema.builder().build()) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("enum must not be empty"); + .hasMessageContaining("enumValues must not be empty"); } @Test void testUntitledSingleSelectEnumSchemaBuilderRejectsEmptyEnumValues() { assertThatThrownBy(() -> McpSchema.UntitledSingleSelectEnumSchema.builder().enumValues(List.of()).build()) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("enum must not be empty"); + .hasMessageContaining("enumValues must not be empty"); } @Test @@ -1923,7 +1943,7 @@ void testTitledSingleSelectEnumSchemaBuilderRejectsEmptyOneOf() { void testLegacyTitledEnumSchemaBuilderRequiresEnumValues() { assertThatThrownBy(() -> McpSchema.LegacyTitledEnumSchema.builder().build()) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("enum must not be empty"); + .hasMessageContaining("enumValues must not be empty"); } @Test @@ -1931,21 +1951,21 @@ void testLegacyTitledEnumSchemaBuilderRequiresEnumValues() { void testLegacyTitledEnumSchemaBuilderRejectsEmptyEnumValues() { assertThatThrownBy(() -> McpSchema.LegacyTitledEnumSchema.builder().enumValues(List.of()).build()) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("enum must not be empty"); + .hasMessageContaining("enumValues must not be empty"); } @Test void testUntitledMultiSelectItemsBuilderRequiresEnumValues() { assertThatThrownBy(() -> McpSchema.UntitledMultiSelectItems.builder().build()) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("enum must not be empty"); + .hasMessageContaining("enumValues must not be empty"); } @Test void testUntitledMultiSelectItemsBuilderRejectsEmptyEnumValues() { assertThatThrownBy(() -> McpSchema.UntitledMultiSelectItems.builder().enumValues(List.of()).build()) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("enum must not be empty"); + .hasMessageContaining("enumValues must not be empty"); } @Test @@ -1964,9 +1984,9 @@ void testTitledMultiSelectItemsBuilderRejectsEmptyAnyOf() { @Test void testUntitledSingleSelectEnumSchemaBuilderSingularAdd() { - var schema = McpSchema.UntitledSingleSelectEnumSchema.builder().enumValues("a", "b").enumValue("c").build(); + var schema = McpSchema.UntitledSingleSelectEnumSchema.builder().enumValues("a", "b").build(); - assertThat(schema.enumValues()).containsExactly("a", "b", "c"); + assertThat(schema.enumValues()).containsExactly("a", "b"); } @Test @@ -1985,40 +2005,29 @@ void testUntitledSingleSelectEnumSchemaBuilderOptionalFields() { @Test void testTitledSingleSelectEnumSchemaBuilderSingularAdd() { - var opt1 = McpSchema.EnumSchemaOption.builder("v1", "Option 1").build(); - var opt2 = McpSchema.EnumSchemaOption.builder("v2", "Option 2").build(); - var schema = McpSchema.TitledSingleSelectEnumSchema.builder().oneOf(opt1).addOneOf(opt2).build(); + var opt1 = new McpSchema.EnumSchemaOption("v1", "Option 1"); + var schema = McpSchema.TitledSingleSelectEnumSchema.builder().oneOf(opt1).build(); - assertThat(schema.oneOf()).hasSize(2); - assertThat(schema.oneOf().get(1).constValue()).isEqualTo("v2"); + assertThat(schema.oneOf()).hasSize(1) + .first() + .extracting(McpSchema.EnumSchemaOption::constValue) + .isEqualTo("v1"); } @Test @SuppressWarnings("deprecation") void testLegacyTitledEnumSchemaBuilderSingularAdds() { - var schema = McpSchema.LegacyTitledEnumSchema.builder() - .enumValues("a", "b") - .enumValue("c") - .enumNames("Alpha", "Beta") - .enumName("Gamma") - .build(); - - assertThat(schema.enumValues()).containsExactly("a", "b", "c"); - assertThat(schema.enumNames()).containsExactly("Alpha", "Beta", "Gamma"); - } - - @Test - void testUntitledMultiSelectItemsBuilderSingularAdd() { - var items = McpSchema.UntitledMultiSelectItems.builder().enumValues("x", "y").enumValue("z").build(); + var schema = McpSchema.LegacyTitledEnumSchema.builder().enumValues("a", "b").enumNames("Alpha", "Beta").build(); - assertThat(items.enumValues()).containsExactly("x", "y", "z"); + assertThat(schema.enumValues()).containsExactly("a", "b"); + assertThat(schema.enumNames()).containsExactly("Alpha", "Beta"); } @Test void testTitledMultiSelectItemsBuilderSingularAdd() { - var opt1 = McpSchema.EnumSchemaOption.builder("v1", "First").build(); - var opt2 = McpSchema.EnumSchemaOption.builder("v2", "Second").build(); - var items = McpSchema.TitledMultiSelectItems.builder().anyOf(opt1).addAnyOf(opt2).build(); + var opt1 = new McpSchema.EnumSchemaOption("v1", "First"); + var opt2 = new McpSchema.EnumSchemaOption("v2", "Second"); + var items = McpSchema.TitledMultiSelectItems.builder().anyOf(opt1, opt2).build(); assertThat(items.anyOf()).hasSize(2); assertThat(items.anyOf().get(1).constValue()).isEqualTo("v2"); @@ -2032,8 +2041,7 @@ void testUntitledMultiSelectEnumSchemaBuilderOptionalFields() { .description("Select tags") .minItems(1) .maxItems(2) - .defaultValue(List.of("a")) - .addDefaultValue("b") + .defaults("a", "b") .build(); assertThat(schema.title()).isEqualTo("Tags"); From 9dbdc0da21fb881e2b9f0266140bfd9309c480e2 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 11 Jun 2026 10:58:57 +0200 Subject: [PATCH 4/4] Add more schema tests --- .../spec/McpSchemaTests.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index b5e23252d..ab9bc8643 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -1743,6 +1743,16 @@ void testEnumSchemaOptionDeserialization() throws Exception { assertThat(option.title()).isEqualTo("Low Priority"); } + @Test + void testEnumSchemaOptionDeserializationWithUnknownField() throws Exception { + var option = JSON_MAPPER.readValue(""" + { + "futureField": 42 + }""", McpSchema.EnumSchemaOption.class); + + assertThat(option).isNotNull(); + } + @Test void testEnumSchemaOptionDeserializationWithBothFieldsMissing() throws Exception { var option = JSON_MAPPER.readValue("{}", McpSchema.EnumSchemaOption.class); @@ -1751,6 +1761,16 @@ void testEnumSchemaOptionDeserializationWithBothFieldsMissing() throws Exception assertThat(option.title()).isEqualTo(""); } + @Test + void testEnumSchemaOptionsRequiredField() { + assertThatThrownBy(() -> new McpSchema.EnumSchemaOption("~~~", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("title must not be null"); + assertThatThrownBy(() -> new McpSchema.EnumSchemaOption(null, "~~~")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("constValue must not be null"); + } + @Test void testUntitledSingleSelectEnumSchemaSerialization() throws Exception { var schema = new McpSchema.UntitledSingleSelectEnumSchema(null, "Choose a color",