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:
parent
6b71136224
commit
accba74c8f
2 changed files with 587 additions and 18 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue