feat: client-side application of elicitation schema defaults (SEP-1034) (#976)

Adds an opt-in McpClient builder option `applyElicitationDefaults(boolean)`
that, when enabled, fills missing keys of an accepted ElicitResult.content
with `default` values declared in the requestedSchema before returning the
result to the server. Mirrors the TypeScript SDK's applyElicitationDefaults
behavior, but exposed as a local client config rather than a wire capability.

Co-authored-by: Sainath Reddy Bobbala <bsnr@amazon.com>
Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com>
This commit is contained in:
Dariusz Jędrzejczyk 2026-05-22 19:51:54 +02:00 • committed by GitHub
commit accba74c8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 587 additions and 18 deletions

View file

@ -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.Map;
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.ElicitRequest;
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<String, RequestHandler<?>> requestHandlers = new HashMap<>();
@ -561,10 +565,57 @@ public class McpAsyncClient {
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<String, Object> 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.
* <p>
* 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<String, Object> schema, Map<String, Object> content) {
if (schema == null || content == null) {
return;
}
Object propertiesObj = schema.get("properties");
if (!(propertiesObj instanceof Map)) {
return;
}
Map<String, Object> properties = (Map<String, Object>) propertiesObj;
for (Map.Entry<String, Object> entry : properties.entrySet()) {
String key = entry.getKey();
Object propDef = entry.getValue();
if (!(propDef instanceof Map)) {
continue;
}
Map<String, Object> propMap = (Map<String, Object>) propDef;
if (!content.containsKey(key) && propMap.containsKey("default")) {
content.put(key, propMap.get("default"));
}
}
}
// --------------------------
// Tools
// --------------------------

View file

@ -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 @@ public interface McpClient {
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 interface McpClient {
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 interface McpClient {
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 @@ public interface McpClient {
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 interface McpClient {
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 interface McpClient {
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));
}
}

View file

@ -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<String, McpSchema.Root> roots, List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers,
@ -73,7 +76,7 @@ class McpClientFeatures {
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers,
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler,
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler,
boolean enableCallToolSchemaCaching) {
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) {
/**
* Create an instance and validate the arguments.
@ -87,6 +90,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}.
*/
public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots,
@ -98,7 +104,7 @@ class McpClientFeatures {
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers,
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler,
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler,
boolean enableCallToolSchemaCaching) {
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) {
Assert.notNull(clientInfo, "Client info must not be null");
this.clientInfo = clientInfo;
@ -119,6 +125,7 @@ class McpClientFeatures {
this.samplingHandler = samplingHandler;
this.elicitationHandler = elicitationHandler;
this.enableCallToolSchemaCaching = enableCallToolSchemaCaching;
this.applyElicitationDefaults = applyElicitationDefaults;
}
/**
@ -135,7 +142,7 @@ class McpClientFeatures {
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler) {
this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers,
resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler,
elicitationHandler, false);
elicitationHandler, false, false);
}
/**
@ -194,7 +201,7 @@ class McpClientFeatures {
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 @@ 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}.
*/
public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots, List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers,
@ -223,7 +233,7 @@ class McpClientFeatures {
List<Consumer<McpSchema.ProgressNotification>> progressConsumers,
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler,
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler,
boolean enableCallToolSchemaCaching) {
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) {
/**
* Create an instance and validate the arguments.
@ -239,6 +249,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}.
*/
public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots, List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers,
@ -249,7 +262,7 @@ class McpClientFeatures {
List<Consumer<McpSchema.ProgressNotification>> progressConsumers,
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler,
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler,
boolean enableCallToolSchemaCaching) {
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) {
Assert.notNull(clientInfo, "Client info must not be null");
this.clientInfo = clientInfo;
@ -270,6 +283,7 @@ class McpClientFeatures {
this.samplingHandler = samplingHandler;
this.elicitationHandler = elicitationHandler;
this.enableCallToolSchemaCaching = enableCallToolSchemaCaching;
this.applyElicitationDefaults = applyElicitationDefaults;
}
/**
@ -285,7 +299,7 @@ class McpClientFeatures {
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler) {
this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers,
resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler,
elicitationHandler, false);
elicitationHandler, false, false);
}
}

View file

@ -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<String, Object> schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest")));
Map<String, Object> content = new HashMap<>();
McpAsyncClient.applyElicitationDefaults(schema, content);
assertThat(content).containsEntry("name", "Guest");
}
@Test
void appliesNumberDefault() {
Map<String, Object> schema = Map.of("properties", Map.of("age", Map.of("type", "integer", "default", 18)));
Map<String, Object> content = new HashMap<>();
McpAsyncClient.applyElicitationDefaults(schema, content);
assertThat(content).containsEntry("age", 18);
}
@Test
void appliesBooleanDefault() {
Map<String, Object> schema = Map.of("properties",
Map.of("subscribe", Map.of("type", "boolean", "default", true)));
Map<String, Object> content = new HashMap<>();
McpAsyncClient.applyElicitationDefaults(schema, content);
assertThat(content).containsEntry("subscribe", true);
}
@Test
void appliesEnumDefault() {
Map<String, Object> schema = Map.of("properties",
Map.of("color", Map.of("type", "string", "enum", List.of("red", "green"), "default", "green")));
Map<String, Object> content = new HashMap<>();
McpAsyncClient.applyElicitationDefaults(schema, content);
assertThat(content).containsEntry("color", "green");
}
@Test
void doesNotOverrideExistingValues() {
Map<String, Object> schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest")));
Map<String, Object> content = new HashMap<>();
content.put("name", "Alice");
McpAsyncClient.applyElicitationDefaults(schema, content);
assertThat(content).containsEntry("name", "Alice");
}
@Test
void skipsPropertiesWithoutDefault() {
Map<String, Object> schema = Map.of("properties", Map.of("email", Map.of("type", "string")));
Map<String, Object> content = new HashMap<>();
McpAsyncClient.applyElicitationDefaults(schema, content);
assertThat(content).doesNotContainKey("email");
}
@Test
void appliesMultipleDefaults() {
Map<String, Object> 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<String, Object> 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<String, Object> content = new HashMap<>();
McpAsyncClient.applyElicitationDefaults(null, content);
assertThat(content).isEmpty();
}
@Test
void handlesNullContent() {
Map<String, Object> schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest")));
// Should not throw
McpAsyncClient.applyElicitationDefaults(schema, null);
}
@Test
void handlesSchemaWithoutProperties() {
Map<String, Object> schema = Map.of("type", "object");
Map<String, Object> content = new HashMap<>();
McpAsyncClient.applyElicitationDefaults(schema, content);
assertThat(content).isEmpty();
}
@Test
void appliesDefaultsOnlyToMissingFields() {
Map<String, Object> schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest"),
"age", Map.of("type", "integer", "default", 18)));
Map<String, Object> content = new HashMap<>();
content.put("name", "John");
McpAsyncClient.applyElicitationDefaults(schema, content);
assertThat(content).containsEntry("name", "John").containsEntry("age", 18);
}
@Test
void appliesFloatingPointDefault() {
Map<String, Object> schema = Map.of("properties", Map.of("score", Map.of("type", "number", "default", 95.5)));
Map<String, Object> content = new HashMap<>();
McpAsyncClient.applyElicitationDefaults(schema, content);
assertThat(content).containsEntry("score", 95.5);
}
}

View file

@ -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.HttpClient;
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 @@ public abstract class AbstractMcpClientServerIntegrationTests {
}
}
@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<McpSchema.ElicitRequest, McpSchema.ElicitResult> 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<McpSchema.ElicitResult> 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<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler = request -> new McpSchema.ElicitResult(
McpSchema.ElicitResult.Action.ACCEPT, Map.of());
CallToolResult callResponse = McpSchema.CallToolResult.builder()
.addContent(new McpSchema.TextContent("CALL RESPONSE"))
.build();
AtomicReference<McpSchema.ElicitResult> 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<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler = request -> new McpSchema.ElicitResult(
McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>());
CallToolResult callResponse = McpSchema.CallToolResult.builder()
.addContent(new McpSchema.TextContent("CALL RESPONSE"))
.build();
AtomicReference<McpSchema.ElicitResult> 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<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler = request -> new McpSchema.ElicitResult(
McpSchema.ElicitResult.Action.DECLINE, new HashMap<>());
CallToolResult callResponse = McpSchema.CallToolResult.builder()
.addContent(new McpSchema.TextContent("CALL RESPONSE"))
.build();
AtomicReference<McpSchema.ElicitResult> 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<String, Object> meta = Map.of("trace-id", "abc-123");
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> 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<McpSchema.ElicitResult> 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) {

View file

@ -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 @@ public class McpSchemaTests {
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