diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index 93fcc332a..eb6bc10e1 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -15,6 +15,9 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import io.modelcontextprotocol.client.LifecycleInitializer.Initialization; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; @@ -30,16 +33,14 @@ import io.modelcontextprotocol.spec.McpSchema.ElicitResult; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; -import io.modelcontextprotocol.util.ToolNameValidator; import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult; import io.modelcontextprotocol.spec.McpSchema.LoggingLevel; import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification; import io.modelcontextprotocol.spec.McpSchema.PaginatedRequest; import io.modelcontextprotocol.spec.McpSchema.Root; import io.modelcontextprotocol.util.Assert; +import io.modelcontextprotocol.util.ToolNameValidator; import io.modelcontextprotocol.util.Utils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -561,10 +562,65 @@ private RequestHandler elicitationCreateHandler() { ElicitRequest request = transport.unmarshalFrom(params, new TypeRef<>() { }); - return this.elicitationHandler.apply(request); + return this.elicitationHandler.apply(request).map(result -> { + // Apply defaults from schema when applyDefaults is enabled + if (result.action() == ElicitResult.Action.ACCEPT && result.content() != null + && shouldApplyElicitationDefaults()) { + Map merged = new HashMap<>(result.content()); + applyElicitationDefaults(request.requestedSchema(), merged); + return new ElicitResult(result.action(), merged, result.meta()); + } + return result; + }); }; } + /** + * Checks whether the client is configured to apply elicitation defaults. + * @return true if the client capabilities indicate that defaults should be applied + */ + private boolean shouldApplyElicitationDefaults() { + if (this.clientCapabilities.elicitation() == null) { + return false; + } + McpSchema.ClientCapabilities.Elicitation.Form form = this.clientCapabilities.elicitation().form(); + return form != null && Boolean.TRUE.equals(form.applyDefaults()); + } + + /** + * Applies default values from the elicitation schema to the result content. For each + * property in the schema that has a "default" value, if the corresponding key is + * missing from the content map, the default value is inserted. + * @param schema the requestedSchema from the ElicitRequest + * @param content the mutable content map from the ElicitResult + */ + @SuppressWarnings("unchecked") + static void applyElicitationDefaults(Map schema, Map content) { + if (schema == null || content == null) { + return; + } + + Object propertiesObj = schema.get("properties"); + if (!(propertiesObj instanceof Map)) { + return; + } + + Map properties = (Map) propertiesObj; + for (Map.Entry entry : properties.entrySet()) { + String key = entry.getKey(); + Object propDef = entry.getValue(); + + if (!(propDef instanceof Map)) { + continue; + } + + Map propMap = (Map) propDef; + if (!content.containsKey(key) && propMap.containsKey("default")) { + content.put(key, propMap.get("default")); + } + } + } + // -------------------------- // Tools // -------------------------- 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 bb9cead7e..7c37274b0 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -11,17 +11,19 @@ import java.util.List; import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; + import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.util.Assert; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Based on the JSON-RPC 2.0 @@ -431,19 +433,34 @@ public record Sampling() { * @param url support for out-of-band URL-based elicitation */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record Elicitation(@JsonProperty("form") Form form, @JsonProperty("url") Url url) { /** - * Marker record indicating support for form-based elicitation mode. + * Record indicating support for form-based elicitation mode. + * + * @param applyDefaults Whether the client should apply default values from + * the schema to the elicitation result content when fields are missing. When + * true, the SDK will automatically fill in missing fields with their + * schema-defined defaults before returning the result to the server. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record Form() { + @JsonIgnoreProperties(ignoreUnknown = true) + public record Form(@JsonProperty("applyDefaults") Boolean applyDefaults) { + + /** + * Creates a Form with default settings (no applyDefaults). + */ + public Form() { + this(null); + } } /** * Marker record indicating support for URL-based elicitation mode. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record Url() { } @@ -507,6 +524,31 @@ public Builder elicitation(boolean form, boolean url) { return this; } + /** + * Enables elicitation capability with form mode and applyDefaults setting. + *

+ * Note: {@code applyDefaults} is an SDK-level behavior flag that controls + * whether the client automatically fills in missing fields from schema + * defaults. It is serialized as part of the capabilities sent to the server + * during initialization, consistent with the TypeScript SDK behavior. Servers + * should tolerate unknown capability fields per the MCP specification. + * @param form whether to support form-based elicitation + * @param url whether to support URL-based elicitation + * @param applyDefaults whether the client should apply schema defaults to + * elicitation results. Requires {@code form} to be {@code true}. + * @return this builder + * @throws IllegalArgumentException if {@code applyDefaults} is {@code true} + * but {@code form} is {@code false} + */ + public Builder elicitation(boolean form, boolean url, boolean applyDefaults) { + if (!form && applyDefaults) { + throw new IllegalArgumentException("applyDefaults requires form to be true"); + } + this.elicitation = new Elicitation(form ? new Elicitation.Form(applyDefaults) : null, + url ? new Elicitation.Url() : null); + return this; + } + public ClientCapabilities build() { return new ClientCapabilities(experimental, roots, sampling, elicitation); } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientElicitationDefaultsTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientElicitationDefaultsTests.java new file mode 100644 index 000000000..f33b31fae --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientElicitationDefaultsTests.java @@ -0,0 +1,150 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link McpAsyncClient#applyElicitationDefaults(Map, Map)}. + * + * Verifies that the client-side default application logic correctly fills in missing + * fields from schema defaults, matching the behavior specified in SEP-1034. + */ +class McpAsyncClientElicitationDefaultsTests { + + @Test + void appliesStringDefault() { + Map schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest"))); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("name", "Guest"); + } + + @Test + void appliesNumberDefault() { + Map schema = Map.of("properties", Map.of("age", Map.of("type", "integer", "default", 18))); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("age", 18); + } + + @Test + void appliesBooleanDefault() { + Map schema = Map.of("properties", + Map.of("subscribe", Map.of("type", "boolean", "default", true))); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("subscribe", true); + } + + @Test + void appliesEnumDefault() { + Map schema = Map.of("properties", + Map.of("color", Map.of("type", "string", "enum", List.of("red", "green"), "default", "green"))); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("color", "green"); + } + + @Test + void doesNotOverrideExistingValues() { + Map schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest"))); + + Map content = new HashMap<>(); + content.put("name", "Alice"); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("name", "Alice"); + } + + @Test + void skipsPropertiesWithoutDefault() { + Map schema = Map.of("properties", Map.of("email", Map.of("type", "string"))); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).doesNotContainKey("email"); + } + + @Test + void appliesMultipleDefaults() { + Map schema = Map.of("properties", + Map.of("name", Map.of("type", "string", "default", "Guest"), "age", + Map.of("type", "integer", "default", 18), "subscribe", + Map.of("type", "boolean", "default", true), "color", + Map.of("type", "string", "enum", List.of("red", "green"), "default", "green"))); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("name", "Guest") + .containsEntry("age", 18) + .containsEntry("subscribe", true) + .containsEntry("color", "green"); + } + + @Test + void handlesNullSchema() { + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(null, content); + + assertThat(content).isEmpty(); + } + + @Test + void handlesNullContent() { + Map schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest"))); + + // Should not throw + McpAsyncClient.applyElicitationDefaults(schema, null); + } + + @Test + void handlesSchemaWithoutProperties() { + Map schema = Map.of("type", "object"); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).isEmpty(); + } + + @Test + void appliesDefaultsOnlyToMissingFields() { + Map schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest"), + "age", Map.of("type", "integer", "default", 18))); + + Map content = new HashMap<>(); + content.put("name", "John"); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("name", "John").containsEntry("age", 18); + } + + @Test + void appliesFloatingPointDefault() { + Map schema = Map.of("properties", Map.of("score", Map.of("type", "number", "default", 95.5))); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("score", 95.5); + } + +} diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java index e5d55c39d..ed44bfc70 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -446,6 +446,137 @@ void testCreateElicitationSuccess(String clientType) { } } + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationWithApplyDefaults(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + // Client handler returns empty content — SDK should apply defaults + Function elicitationHandler = request -> { + assertThat(request.message()).isNotEmpty(); + assertThat(request.requestedSchema()).isNotNull(); + // Return accept with empty content, simulating a user who didn't fill + // anything + return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, new java.util.HashMap<>()); + }; + + CallToolResult callResponse = McpSchema.CallToolResult.builder() + .addContent(new McpSchema.TextContent("CALL RESPONSE")) + .build(); + + AtomicReference elicitResultRef = new AtomicReference<>(); + + McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() + .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) + .callHandler((exchange, request) -> { + + var elicitationRequest = McpSchema.ElicitRequest.builder() + .message("Provide your preferences") + .requestedSchema(Map.of("type", "object", "properties", + Map.of("nickname", Map.of("type", "string", "default", "Guest"), "age", + Map.of("type", "integer", "default", 18), "subscribe", + Map.of("type", "boolean", "default", true), "color", + Map.of("type", "string", "enum", java.util.List.of("red", "green"), "default", + "green")), + "required", java.util.List.of("nickname", "age", "subscribe", "color"))) + .build(); + + return exchange.createElicitation(elicitationRequest) + .doOnNext(elicitResultRef::set) + .thenReturn(callResponse); + }) + .build(); + + var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); + + // Enable applyDefaults via the capability + try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + .capabilities(ClientCapabilities.builder().elicitation(true, false, true).build()) + .elicitation(elicitationHandler) + .build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + + assertThat(response).isNotNull(); + assertWith(elicitResultRef.get(), result -> { + assertThat(result).isNotNull(); + assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); + assertThat(result.content()).containsEntry("nickname", "Guest"); + assertThat(result.content()).containsEntry("age", 18); + assertThat(result.content()).containsEntry("subscribe", true); + assertThat(result.content()).containsEntry("color", "green"); + }); + } + finally { + mcpServer.closeGracefully().block(); + } + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationWithApplyDefaultsAndUnmodifiableMap(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + // Client handler returns an unmodifiable map (Map.of()) — SDK should handle this + // gracefully by copying into a new HashMap before applying defaults + Function elicitationHandler = request -> { + return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of()); + }; + + CallToolResult callResponse = McpSchema.CallToolResult.builder() + .addContent(new McpSchema.TextContent("CALL RESPONSE")) + .build(); + + AtomicReference elicitResultRef = new AtomicReference<>(); + + McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() + .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) + .callHandler((exchange, request) -> { + + var elicitationRequest = McpSchema.ElicitRequest.builder() + .message("Provide your preferences") + .requestedSchema(Map.of("type", "object", "properties", + Map.of("nickname", Map.of("type", "string", "default", "Guest"), "age", + Map.of("type", "integer", "default", 18)), + "required", java.util.List.of("nickname", "age"))) + .build(); + + return exchange.createElicitation(elicitationRequest) + .doOnNext(elicitResultRef::set) + .thenReturn(callResponse); + }) + .build(); + + var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); + + try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + .capabilities(ClientCapabilities.builder().elicitation(true, false, true).build()) + .elicitation(elicitationHandler) + .build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + + assertThat(response).isNotNull(); + assertWith(elicitResultRef.get(), result -> { + assertThat(result).isNotNull(); + assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); + assertThat(result.content()).containsEntry("nickname", "Guest"); + assertThat(result.content()).containsEntry("age", 18); + }); + } + finally { + mcpServer.closeGracefully().block(); + } + } + @ParameterizedTest(name = "{0} : {displayName} ") @MethodSource("clientsForTesting") void testCreateElicitationWithRequestTimeoutSuccess(String clientType) { 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 942e0a6e2..d08971d07 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -4,12 +4,6 @@ package io.modelcontextprotocol.spec; -import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - import java.io.IOException; import java.util.Arrays; import java.util.Collections; @@ -17,10 +11,15 @@ import java.util.List; import java.util.Map; -import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; +import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; +import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; import net.javacrumbs.jsonunit.core.Option; /** @@ -1717,6 +1716,70 @@ void testElicitationCapabilityBuilderFormOnly() throws Exception { assertThat(json).doesNotContain("\"url\""); } + @Test + void testElicitationCapabilityWithApplyDefaults() throws Exception { + McpSchema.ClientCapabilities capabilities = McpSchema.ClientCapabilities.builder() + .elicitation(true, false, true) + .build(); + + assertThat(capabilities.elicitation()).isNotNull(); + assertThat(capabilities.elicitation().form()).isNotNull(); + assertThat(capabilities.elicitation().form().applyDefaults()).isTrue(); + + String json = JSON_MAPPER.writeValueAsString(capabilities); + assertThatJson(json).node("elicitation.form.applyDefaults").isEqualTo(true); + } + + @Test + void testElicitationCapabilityApplyDefaultsSerializationRoundTrip() throws Exception { + McpSchema.ClientCapabilities original = McpSchema.ClientCapabilities.builder() + .elicitation(true, false, true) + .build(); + + String json = JSON_MAPPER.writeValueAsString(original); + McpSchema.ClientCapabilities deserialized = JSON_MAPPER.readValue(json, McpSchema.ClientCapabilities.class); + + assertThat(deserialized.elicitation()).isNotNull(); + assertThat(deserialized.elicitation().form()).isNotNull(); + assertThat(deserialized.elicitation().form().applyDefaults()).isTrue(); + } + + @Test + void testElicitationCapabilityFormWithoutApplyDefaults() throws Exception { + // Form without applyDefaults should not include it in JSON + McpSchema.ClientCapabilities capabilities = McpSchema.ClientCapabilities.builder() + .elicitation(true, false) + .build(); + + assertThat(capabilities.elicitation().form().applyDefaults()).isNull(); + + String json = JSON_MAPPER.writeValueAsString(capabilities); + assertThat(json).doesNotContain("\"applyDefaults\""); + } + + @Test + void testElicitRequestWithDefaultValues() throws Exception { + // Test that schemas with default values serialize correctly in an ElicitRequest + McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder() + .message("Please provide your info") + .requestedSchema(Map.of("type", "object", "properties", + Map.of("name", Map.of("type", "string", "default", "John Doe"), "age", + Map.of("type", "integer", "default", 30), "score", + Map.of("type", "number", "default", 95.5), "status", + Map.of("type", "string", "enum", List.of("active", "inactive"), "default", "active"), + "verified", Map.of("type", "boolean", "default", true)), + "required", List.of("name"))) + .build(); + + String value = JSON_MAPPER.writeValueAsString(request); + + assertThatJson(value).node("requestedSchema.properties.name.default").isEqualTo("John Doe"); + assertThatJson(value).node("requestedSchema.properties.age.default").isEqualTo(30); + assertThatJson(value).node("requestedSchema.properties.score.default").isEqualTo(95.5); + assertThatJson(value).node("requestedSchema.properties.status.default").isEqualTo("active"); + assertThatJson(value).node("requestedSchema.properties.verified.default").isEqualTo(true); + } + // Progress Notification Tests @Test