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
2 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;
@ -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