Add schemas for form-based elicitation (#1020)

* Add StringSchema, BooleanSchema and NumberSchema
* Add enum schemas for form-based elicitation matching SEP-1330

Closes #691

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
Daniel Garnier-Moiroux 2026-06-11 11:16:47 +02:00 • committed by GitHub
commit fe82ad508a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1531 additions and 31 deletions

View file

@ -5,11 +5,12 @@ import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator; import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.AudioContent; import io.modelcontextprotocol.spec.McpSchema.AudioContent;
import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents; import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult; import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
@ -43,6 +44,16 @@ import org.apache.catalina.startup.Tomcat;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; 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 { public class ConformanceServlet {
private static final Logger logger = LoggerFactory.getLogger(ConformanceServlet.class); private static final Logger logger = LoggerFactory.getLogger(ConformanceServlet.class);
@ -141,6 +152,7 @@ public class ConformanceServlet {
return tomcat; return tomcat;
} }
@SuppressWarnings("deprecation")
private static List<McpServerFeatures.SyncToolSpecification> createToolSpecs() { private static List<McpServerFeatures.SyncToolSpecification> createToolSpecs() {
return List.of( return List.of(
// test_simple_text - Returns simple text content // test_simple_text - Returns simple text content
@ -406,8 +418,8 @@ public class ConformanceServlet {
// json_schema_2020_12_tool - SEP-1613 dialect/keyword preservation // json_schema_2020_12_tool - SEP-1613 dialect/keyword preservation
McpServerFeatures.SyncToolSpecification.builder() McpServerFeatures.SyncToolSpecification.builder()
.tool(Tool .tool(Tool
.builder("json_schema_2020_12_tool", Map.of("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12, .builder("json_schema_2020_12_tool", Map.of("$schema", JSON_SCHEMA_DIALECT_2020_12, "type",
"type", "object", "$defs", "object", "$defs",
Map.of("address", Map.of("address",
Map.of("type", "object", "properties", Map.of("type", "object", "properties",
Map.of("street", Map.of("type", "string"), "city", Map.of("street", Map.of("type", "string"), "city",
@ -434,33 +446,44 @@ public class ConformanceServlet {
.callHandler((exchange, request) -> { .callHandler((exchange, request) -> {
logger.info("Tool 'test_elicitation_sep1330_enums' called"); logger.info("Tool 'test_elicitation_sep1330_enums' called");
// Create schema with all 5 enum variants TypeRef<Map<String, Object>> mapType = new TypeRef<>() {
Map<String, Object> requestedSchema = Map.of("type", "object", "properties", Map.of( };
var mapper = McpJsonDefaults.getMapper();
// 1. Untitled single-select // 1. Untitled single-select
"untitledSingle", var untitledSingle = UntitledSingleSelectEnumSchema.builder()
Map.of("type", "string", "enum", List.of("option1", "option2", "option3")), .enumValues("option1", "option2", "option3")
.build();
// 2. Titled single-select using oneOf with const/title // 2. Titled single-select using oneOf with const/title
"titledSingle", var titledSingle = TitledSingleSelectEnumSchema.builder()
Map.of("type", "string", "oneOf", .oneOf(new EnumSchemaOption("value1", "First Option"),
List.of(Map.of("const", "value1", "title", "First Option"), new EnumSchemaOption("value2", "Second Option"),
Map.of("const", "value2", "title", "Second Option"), new EnumSchemaOption("value3", "Third Option"))
Map.of("const", "value3", "title", "Third Option"))), .build();
// 3. Legacy titled using enumNames (deprecated) // 3. Legacy titled using enumNames (deprecated)
"legacyEnum", var legacyEnum = LegacyTitledEnumSchema.builder()
Map.of("type", "string", "enum", List.of("opt1", "opt2", "opt3"), "enumNames", .enumValues("opt1", "opt2", "opt3")
List.of("Option One", "Option Two", "Option Three")), .enumNames("Option One", "Option Two", "Option Three")
.build();
// 4. Untitled multi-select // 4. Untitled multi-select
"untitledMulti", var untitledMulti = UntitledMultiSelectEnumSchema.builder(
Map.of("type", "array", "items", UntitledMultiSelectItems.builder().enumValues("option1", "option2", "option3").build())
Map.of("type", "string", "enum", List.of("option1", "option2", "option3"))), .build();
// 5. Titled multi-select using items.anyOf with // 5. Titled multi-select using items.anyOf with const/title
// const/title var titledMulti = TitledMultiSelectEnumSchema
"titledMulti", .builder(TitledMultiSelectItems.builder()
Map.of("type", "array", "items", .anyOf(new EnumSchemaOption("value1", "First Choice"),
Map.of("anyOf", new EnumSchemaOption("value2", "Second Choice"),
List.of(Map.of("const", "value1", "title", "First Choice"), new EnumSchemaOption("value3", "Third Choice"))
Map.of("const", "value2", "title", "Second Choice"), .build())
Map.of("const", "value3", "title", "Third Choice"))))), .build();
Map<String, Object> 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", "required", List.of("untitledSingle", "titledSingle", "legacyEnum", "untitledMulti",
"titledMulti")); "titledMulti"));

View file

@ -6,6 +6,7 @@ package io.modelcontextprotocol.spec;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -3888,6 +3889,817 @@ public final class McpSchema {
} }
// Elicitation // 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 EnumSchemaOption {
Assert.notNull(constValue, "constValue must not be null");
Assert.notNull(title, "title must not be null");
}
@JsonCreator
static EnumSchemaOption fromJson(@JsonProperty("const") String constValue,
@JsonProperty("title") String title) {
if (constValue == null || title == null) {
List<String> 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);
}
}
/**
* 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<String> enumValues,
@JsonProperty("enumNames") List<String> enumNames,
@JsonProperty("default") String defaultValue) { // @formatter:on
public LegacyTitledEnumSchema {
Assert.notNull(enumValues, "enumValues 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<String> enumValues;
private List<String> 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<String> enumValues) {
Assert.notNull(enumValues, "enumValues must not be null");
this.enumValues = new ArrayList<>(enumValues);
return this;
}
public Builder enumValues(String... enumValues) {
Assert.notNull(enumValues, "enumValues must not be null");
this.enumValues = Arrays.asList(enumValues);
return this;
}
public Builder enumNames(List<String> enumNames) {
Assert.notNull(enumNames, "enumNames must not be null");
this.enumNames = new ArrayList<>(enumNames);
return this;
}
public Builder enumNames(String... enumNames) {
Assert.notNull(enumNames, "enumNames must not be null");
this.enumNames = Arrays.asList(enumNames);
return this;
}
public Builder defaultValue(String defaultValue) {
this.defaultValue = defaultValue;
return this;
}
public LegacyTitledEnumSchema build() {
Assert.notEmpty(enumValues, "enumValues 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<String> enumValues,
@JsonProperty("default") String defaultValue) { // @formatter:on
public UntitledSingleSelectEnumSchema {
Assert.notNull(enumValues, "enumValues 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<String> 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<String> enumValues) {
Assert.notNull(enumValues, "enumValues must not be null");
this.enumValues = new ArrayList<>(enumValues);
return this;
}
public Builder enumValues(String... enumValues) {
Assert.notNull(enumValues, "enumValues must not be null");
this.enumValues = Arrays.asList(enumValues);
return this;
}
public Builder defaultValue(String defaultValue) {
this.defaultValue = defaultValue;
return this;
}
public UntitledSingleSelectEnumSchema build() {
Assert.notEmpty(enumValues, "enumValues 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<EnumSchemaOption> 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<EnumSchemaOption> 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<EnumSchemaOption> 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 = Arrays.asList(oneOf);
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<String> enumValues) { // @formatter:on
public UntitledMultiSelectItems {
Assert.notNull(enumValues, "enumValues must not be null");
}
@JsonProperty("type")
public String type() {
return "string";
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private List<String> enumValues;
private Builder() {
}
public Builder enumValues(List<String> enumValues) {
Assert.notNull(enumValues, "enumValues must not be null");
this.enumValues = new ArrayList<>(enumValues);
return this;
}
public Builder enumValues(String... enumValues) {
Assert.notNull(enumValues, "enumValues must not be null");
this.enumValues = Arrays.asList(enumValues);
return this;
}
public UntitledMultiSelectItems build() {
Assert.notEmpty(enumValues, "enumValues 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<String> 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<String> 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 defaults(String... defaultValue) {
Assert.notNull(defaultValue, "defaultValue must not be null");
this.defaultValue = Arrays.asList(defaultValue);
return this;
}
public Builder defaults(List<String> defaultValue) {
Assert.notNull(defaultValue, "defaultValue must not be null");
this.defaultValue = new ArrayList<>(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<EnumSchemaOption> 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<EnumSchemaOption> anyOf;
private Builder() {
}
public Builder anyOf(List<EnumSchemaOption> 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 = Arrays.asList(anyOf);
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<String> 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<String> 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 defaults(List<String> defaultValue) {
Assert.notNull(defaultValue, "defaultValue must not be null");
this.defaultValue = new ArrayList<>(defaultValue);
return this;
}
public Builder defaults(String... defaultValue) {
Assert.notNull(defaultValue, "defaultValue must not be null");
this.defaultValue = Arrays.asList(defaultValue);
return this;
}
public TitledMultiSelectEnumSchema build() {
return new TitledMultiSelectEnumSchema(title, description, items, minItems, maxItems, defaultValue);
}
}
}
/**
* 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 * A request from the server to elicit additional information from the user, either
* through the client or out-of-band. * through the client or out-of-band.
@ -3930,13 +4742,48 @@ public final class McpSchema {
/** /**
* A request from the server to elicit additional information from the user via the * A request from the server to elicit additional information from the user via the
* client, using {@code form} mode. * client, using {@code form} mode.
* <p>
* The requested schema is flexible, but for standard schemas, consider using one the
* following types:
* <ul>
* <li>{@link BooleanSchema}
* <li>{@link NumberSchema}
* <li>{@link StringSchema}
* <li>{@link LegacyTitledEnumSchema}
* <li>{@link TitledSingleSelectEnumSchema}
* <li>{@link TitledMultiSelectEnumSchema}
* <li>{@link UntitledSingleSelectEnumSchema}
* <li>{@link UntitledMultiSelectEnumSchema}
* </ul>
*
* These can be used with a JSON mapper:
*
* <pre>
* var mapper = McpJsonDefaults.getMapper();
* TypeRef&lt;Map&lt;String, Object&gt;&gt; mapType = new TypeRef<>() { };
* var first = UntitledSingleSelectEnumSchema.builder()
* .enumValues("option1", "option2", "option3")
* .build();
* var second = BooleanSchema
* .builder()
* .title("Say yes")
* .description("By selecting this, you say yes to the thing")
* .build();
* Map&lt;String, Object&gt; 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"));
* </pre>
* *
* @param message The message to present to the user * @param message The message to present to the user
* @param requestedSchema A restricted subset of JSON Schema. Only top-level * @param requestedSchema A restricted subset of JSON Schema. Only top-level
* properties are allowed, without nesting. Per SEP-1613, the dialect defaults to JSON * 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 * 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} 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 * @param meta See specification for notes on _meta usage
* <p> * <p>
* Note: {@code message} and {@code requestedSchema} are required by the MCP * Note: {@code message} and {@code requestedSchema} are required by the MCP

View file

@ -16,6 +16,9 @@ import io.modelcontextprotocol.spec.McpSchema.TextResourceContents;
import net.javacrumbs.jsonunit.core.Option; import net.javacrumbs.jsonunit.core.Option;
import org.assertj.core.api.InstanceOfAssertFactories; import org.assertj.core.api.InstanceOfAssertFactories;
import org.junit.jupiter.api.Test; 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 io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
@ -1726,6 +1729,633 @@ public class McpSchemaTests {
assertThat(request.message()).isEqualTo("hello"); assertThat(request.message()).isEqualTo("hello");
} }
// 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 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);
assertThat(option.constValue()).isEqualTo("");
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",
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("enumValues must not be empty");
}
@Test
void testUntitledSingleSelectEnumSchemaBuilderRejectsEmptyEnumValues() {
assertThatThrownBy(() -> McpSchema.UntitledSingleSelectEnumSchema.builder().enumValues(List.of()).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("enumValues 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("enumValues must not be empty");
}
@Test
@SuppressWarnings("deprecation")
void testLegacyTitledEnumSchemaBuilderRejectsEmptyEnumValues() {
assertThatThrownBy(() -> McpSchema.LegacyTitledEnumSchema.builder().enumValues(List.of()).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("enumValues must not be empty");
}
@Test
void testUntitledMultiSelectItemsBuilderRequiresEnumValues() {
assertThatThrownBy(() -> McpSchema.UntitledMultiSelectItems.builder().build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("enumValues must not be empty");
}
@Test
void testUntitledMultiSelectItemsBuilderRejectsEmptyEnumValues() {
assertThatThrownBy(() -> McpSchema.UntitledMultiSelectItems.builder().enumValues(List.of()).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("enumValues 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").build();
assertThat(schema.enumValues()).containsExactly("a", "b");
}
@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 = new McpSchema.EnumSchemaOption("v1", "Option 1");
var schema = McpSchema.TitledSingleSelectEnumSchema.builder().oneOf(opt1).build();
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").enumNames("Alpha", "Beta").build();
assertThat(schema.enumValues()).containsExactly("a", "b");
assertThat(schema.enumNames()).containsExactly("Alpha", "Beta");
}
@Test
void testTitledMultiSelectItemsBuilderSingularAdd() {
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");
}
@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)
.defaults("a", "b")
.build();
assertThat(schema.title()).isEqualTo("Tags");
assertThat(schema.minItems()).isEqualTo(1);
assertThat(schema.maxItems()).isEqualTo(2);
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 // Pagination Tests
@Test @Test