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
4 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);
}
}