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 f984426c7..5556cd36e 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.client; @@ -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; @@ -171,6 +172,8 @@ public class McpAsyncClient { */ private final boolean enableCallToolSchemaCaching; + private final boolean applyElicitationDefaults; + /** * Create a new McpAsyncClient with the given transport and session request-response * timeout. @@ -195,6 +198,7 @@ public class McpAsyncClient { this.jsonSchemaValidator = jsonSchemaValidator; this.toolsOutputSchemaCache = new ConcurrentHashMap<>(); this.enableCallToolSchemaCaching = features.enableCallToolSchemaCaching(); + this.applyElicitationDefaults = features.applyElicitationDefaults(); // Request Handlers Map> requestHandlers = new HashMap<>(); @@ -561,10 +565,57 @@ private RequestHandler elicitationCreateHandler() { ElicitRequest request = transport.unmarshalFrom(params, new TypeRef<>() { }); - return this.elicitationHandler.apply(request); + return this.elicitationHandler.apply(request).map(result -> { + if (this.applyElicitationDefaults && result.action() == ElicitResult.Action.ACCEPT + && result.content() != null) { + Map merged = new HashMap<>(result.content()); + applyElicitationDefaults(request.requestedSchema(), merged); + return new ElicitResult(result.action(), merged, result.meta()); + } + return result; + }); }; } + /** + * Applies default values from the elicitation schema into a result-content map: for + * each top-level property in {@code schema.properties} that declares a + * {@code "default"}, the value is inserted into {@code content} when the key is + * absent. + *

+ * Only top-level properties are visited; nested objects and {@code anyOf}/ + * {@code oneOf} branches are not traversed. This is sufficient for SEP-1034's flat + * elicitation primitive schemas (string, number, boolean, enum). + * @param schema the {@code requestedSchema} from the {@link ElicitRequest} + * @param content the mutable content map to update + */ + @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/client/McpClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java index 2bba792d5..fe3e902e2 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.client; @@ -195,6 +195,8 @@ class SyncSpec { private boolean enableCallToolSchemaCaching = false; // Default to false + private boolean applyElicitationDefaults = false; // Default to false + private SyncSpec(McpClientTransport transport) { Assert.notNull(transport, "Transport must not be null"); this.transport = transport; @@ -479,6 +481,19 @@ public SyncSpec enableCallToolSchemaCaching(boolean enableCallToolSchemaCaching) return this; } + /** + * Enables SDK-side merging of elicitation schema defaults into an accepted + * {@link ElicitResult}'s {@code content} for fields the elicitation handler left + * unset. This is a client-local behavior and is NOT serialized as part of the MCP + * capability handshake. + * @param applyElicitationDefaults true to enable, false to disable + * @return This builder instance for method chaining + */ + public SyncSpec applyElicitationDefaults(boolean applyElicitationDefaults) { + this.applyElicitationDefaults = applyElicitationDefaults; + return this; + } + /** * Create an instance of {@link McpSyncClient} with the provided configurations or * sensible defaults. @@ -488,7 +503,7 @@ public McpSyncClient build() { McpClientFeatures.Sync syncFeatures = new McpClientFeatures.Sync(this.clientInfo, this.capabilities, this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers, this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers, this.samplingHandler, - this.elicitationHandler, this.enableCallToolSchemaCaching); + this.elicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults); McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures); @@ -549,6 +564,8 @@ class AsyncSpec { private boolean enableCallToolSchemaCaching = false; // Default to false + private boolean applyElicitationDefaults = false; // Default to false + private AsyncSpec(McpClientTransport transport) { Assert.notNull(transport, "Transport must not be null"); this.transport = transport; @@ -820,6 +837,19 @@ public AsyncSpec enableCallToolSchemaCaching(boolean enableCallToolSchemaCaching return this; } + /** + * Enables SDK-side merging of elicitation schema defaults into an accepted + * {@link ElicitResult}'s {@code content} for fields the elicitation handler left + * unset. This is a client-local behavior and is NOT serialized as part of the MCP + * capability handshake. + * @param applyElicitationDefaults true to enable, false to disable + * @return This builder instance for method chaining + */ + public AsyncSpec applyElicitationDefaults(boolean applyElicitationDefaults) { + this.applyElicitationDefaults = applyElicitationDefaults; + return this; + } + /** * Create an instance of {@link McpAsyncClient} with the provided configurations * or sensible defaults. @@ -833,7 +863,8 @@ public McpAsyncClient build() { new McpClientFeatures.Async(this.clientInfo, this.capabilities, this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers, this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers, - this.samplingHandler, this.elicitationHandler, this.enableCallToolSchemaCaching)); + this.samplingHandler, this.elicitationHandler, this.enableCallToolSchemaCaching, + this.applyElicitationDefaults)); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java index fcf3b7263..21e495d35 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.client; @@ -63,6 +63,9 @@ class McpClientFeatures { * @param samplingHandler the sampling handler. * @param elicitationHandler the elicitation handler. * @param enableCallToolSchemaCaching whether to enable call tool schema caching. + * @param applyElicitationDefaults whether the client should fill in missing fields of + * an accepted {@code ElicitResult.content} with the {@code default} values declared + * in the {@code requestedSchema}. */ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, List, Mono>> toolsChangeConsumers, @@ -73,7 +76,7 @@ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c List>> progressConsumers, Function> samplingHandler, Function> elicitationHandler, - boolean enableCallToolSchemaCaching) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { /** * Create an instance and validate the arguments. @@ -87,6 +90,9 @@ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c * @param samplingHandler the sampling handler. * @param elicitationHandler the elicitation handler. * @param enableCallToolSchemaCaching whether to enable call tool schema caching. + * @param applyElicitationDefaults whether the client should fill in missing + * fields of an accepted {@code ElicitResult.content} with the {@code default} + * values declared in the {@code requestedSchema}. */ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, @@ -98,7 +104,7 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c List>> progressConsumers, Function> samplingHandler, Function> elicitationHandler, - boolean enableCallToolSchemaCaching) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { Assert.notNull(clientInfo, "Client info must not be null"); this.clientInfo = clientInfo; @@ -119,6 +125,7 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c this.samplingHandler = samplingHandler; this.elicitationHandler = elicitationHandler; this.enableCallToolSchemaCaching = enableCallToolSchemaCaching; + this.applyElicitationDefaults = applyElicitationDefaults; } /** @@ -135,7 +142,7 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c Function> elicitationHandler) { this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler, - elicitationHandler, false); + elicitationHandler, false, false); } /** @@ -194,7 +201,7 @@ public static Async fromSync(Sync syncSpec) { return new Async(syncSpec.clientInfo(), syncSpec.clientCapabilities(), syncSpec.roots(), toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, progressConsumers, samplingHandler, elicitationHandler, - syncSpec.enableCallToolSchemaCaching); + syncSpec.enableCallToolSchemaCaching, syncSpec.applyElicitationDefaults); } } @@ -213,6 +220,9 @@ public static Async fromSync(Sync syncSpec) { * @param samplingHandler the sampling handler. * @param elicitationHandler the elicitation handler. * @param enableCallToolSchemaCaching whether to enable call tool schema caching. + * @param applyElicitationDefaults whether the client should fill in missing fields of + * an accepted {@code ElicitResult.content} with the {@code default} values declared + * in the {@code requestedSchema}. */ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, List>> toolsChangeConsumers, @@ -223,7 +233,7 @@ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabili List> progressConsumers, Function samplingHandler, Function elicitationHandler, - boolean enableCallToolSchemaCaching) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { /** * Create an instance and validate the arguments. @@ -239,6 +249,9 @@ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabili * @param samplingHandler the sampling handler. * @param elicitationHandler the elicitation handler. * @param enableCallToolSchemaCaching whether to enable call tool schema caching. + * @param applyElicitationDefaults whether the client should fill in missing + * fields of an accepted {@code ElicitResult.content} with the {@code default} + * values declared in the {@code requestedSchema}. */ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, List>> toolsChangeConsumers, @@ -249,7 +262,7 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl List> progressConsumers, Function samplingHandler, Function elicitationHandler, - boolean enableCallToolSchemaCaching) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { Assert.notNull(clientInfo, "Client info must not be null"); this.clientInfo = clientInfo; @@ -270,6 +283,7 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl this.samplingHandler = samplingHandler; this.elicitationHandler = elicitationHandler; this.enableCallToolSchemaCaching = enableCallToolSchemaCaching; + this.applyElicitationDefaults = applyElicitationDefaults; } /** @@ -285,7 +299,7 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl Function elicitationHandler) { this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler, - elicitationHandler, false); + elicitationHandler, false, false); } } 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..e93e64129 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientElicitationDefaultsTests.java @@ -0,0 +1,151 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +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 3e4ac4837..b9e03647e 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 - 2024 the original author or authors. + * Copyright 2024 - 2026 the original author or authors. */ package io.modelcontextprotocol; @@ -11,6 +11,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -468,6 +469,304 @@ 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 new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, new 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", List.of("red", "green"), "default", "green")), + "required", 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(); + + try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + .capabilities(ClientCapabilities.builder().elicitation(true, false).build()) + .elicitation(elicitationHandler) + .applyElicitationDefaults(true) + .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 must copy into a + // mutable map before applying defaults. + Function elicitationHandler = request -> 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", 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).build()) + .elicitation(elicitationHandler) + .applyElicitationDefaults(true) + .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 testCreateElicitationApplyDefaultsDisabledLeavesContentUntouched(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + Function elicitationHandler = request -> new McpSchema.ElicitResult( + McpSchema.ElicitResult.Action.ACCEPT, new 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")))) + .build(); + + return exchange.createElicitation(elicitationRequest) + .doOnNext(elicitResultRef::set) + .thenReturn(callResponse); + }) + .build(); + + var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); + + // applyElicitationDefaults intentionally NOT called — default false. + try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + .capabilities(ClientCapabilities.builder().elicitation(true, false).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()).doesNotContainKey("nickname"); + }); + } + finally { + mcpServer.closeGracefully().block(); + } + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationApplyDefaultsSkippedOnDecline(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + Function elicitationHandler = request -> new McpSchema.ElicitResult( + McpSchema.ElicitResult.Action.DECLINE, new 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")))) + .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).build()) + .elicitation(elicitationHandler) + .applyElicitationDefaults(true) + .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.DECLINE); + assertThat(result.content()).doesNotContainKey("nickname"); + }); + } + finally { + mcpServer.closeGracefully().block(); + } + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationApplyDefaultsPreservesMeta(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + Map meta = Map.of("trace-id", "abc-123"); + Function elicitationHandler = request -> new McpSchema.ElicitResult( + McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>(), meta); + + 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")))) + .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).build()) + .elicitation(elicitationHandler) + .applyElicitationDefaults(true) + .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.meta()).containsEntry("trace-id", "abc-123"); + }); + } + 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 6479fb508..3b0452981 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -1,5 +1,5 @@ /* -* Copyright 2025 - 2025 the original author or authors. +* Copyright 2025 - 2026 the original author or authors. */ package io.modelcontextprotocol.spec; @@ -1886,6 +1886,29 @@ void testElicitationCapabilityBuilderFormOnly() throws Exception { assertThat(json).doesNotContain("\"url\""); } + @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