Skip to content

Commit 7e99b5b

Browse files
author
Sainath Reddy Bobbala
committed
feat: Add SEP-1034 elicitation schema default values
Add client-side default application for elicitation schemas, matching the behavior specified in SEP-1034 and implemented in the TypeScript SDK. - Add applyDefaults field to Elicitation.Form capability record - Add applyElicitationDefaults() in McpAsyncClient to fill missing fields from schema defaults when the client returns accept with incomplete content - Add builder method elicitation(form, url, applyDefaults) - Add unit tests for applyElicitationDefaults covering all primitive types - Add schema serialization tests for applyDefaults capability - Add integration test verifying end-to-end default application flow Closes #699
1 parent 5e77762 commit 7e99b5b

5 files changed

Lines changed: 377 additions & 3 deletions

File tree

mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,10 +561,69 @@ private RequestHandler<ElicitResult> elicitationCreateHandler() {
561561
ElicitRequest request = transport.unmarshalFrom(params, new TypeRef<>() {
562562
});
563563

564-
return this.elicitationHandler.apply(request);
564+
return this.elicitationHandler.apply(request).map(result -> {
565+
// Apply defaults from schema when applyDefaults is enabled
566+
if (result.action() == ElicitResult.Action.ACCEPT && result.content() != null
567+
&& shouldApplyElicitationDefaults()) {
568+
try {
569+
applyElicitationDefaults(request.requestedSchema(), result.content());
570+
}
571+
catch (Exception e) {
572+
// Gracefully ignore errors in default application
573+
logger.debug("Error applying elicitation defaults: {}", e.getMessage());
574+
}
575+
}
576+
return result;
577+
});
565578
};
566579
}
567580

581+
/**
582+
* Checks whether the client is configured to apply elicitation defaults.
583+
* @return true if the client capabilities indicate that defaults should be applied
584+
*/
585+
private boolean shouldApplyElicitationDefaults() {
586+
if (this.clientCapabilities.elicitation() == null) {
587+
return false;
588+
}
589+
McpSchema.ClientCapabilities.Elicitation.Form form = this.clientCapabilities.elicitation().form();
590+
return form != null && Boolean.TRUE.equals(form.applyDefaults());
591+
}
592+
593+
/**
594+
* Applies default values from the elicitation schema to the result content. For each
595+
* property in the schema that has a "default" value, if the corresponding key is
596+
* missing from the content map, the default value is inserted.
597+
* @param schema the requestedSchema from the ElicitRequest
598+
* @param content the mutable content map from the ElicitResult
599+
*/
600+
@SuppressWarnings("unchecked")
601+
static void applyElicitationDefaults(Map<String, Object> schema, Map<String, Object> content) {
602+
if (schema == null || content == null) {
603+
return;
604+
}
605+
606+
Object propertiesObj = schema.get("properties");
607+
if (!(propertiesObj instanceof Map)) {
608+
return;
609+
}
610+
611+
Map<String, Object> properties = (Map<String, Object>) propertiesObj;
612+
for (Map.Entry<String, Object> entry : properties.entrySet()) {
613+
String key = entry.getKey();
614+
Object propDef = entry.getValue();
615+
616+
if (!(propDef instanceof Map)) {
617+
continue;
618+
}
619+
620+
Map<String, Object> propMap = (Map<String, Object>) propDef;
621+
if (!content.containsKey(key) && propMap.containsKey("default")) {
622+
content.put(key, propMap.get("default"));
623+
}
624+
}
625+
}
626+
568627
// --------------------------
569628
// Tools
570629
// --------------------------

mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -431,19 +431,34 @@ public record Sampling() {
431431
* @param url support for out-of-band URL-based elicitation
432432
*/
433433
@JsonInclude(JsonInclude.Include.NON_ABSENT)
434+
@JsonIgnoreProperties(ignoreUnknown = true)
434435
public record Elicitation(@JsonProperty("form") Form form, @JsonProperty("url") Url url) {
435436

436437
/**
437-
* Marker record indicating support for form-based elicitation mode.
438+
* Record indicating support for form-based elicitation mode.
439+
*
440+
* @param applyDefaults Whether the client should apply default values from
441+
* the schema to the elicitation result content when fields are missing. When
442+
* true, the SDK will automatically fill in missing fields with their
443+
* schema-defined defaults before returning the result to the server.
438444
*/
439445
@JsonInclude(JsonInclude.Include.NON_ABSENT)
440-
public record Form() {
446+
@JsonIgnoreProperties(ignoreUnknown = true)
447+
public record Form(@JsonProperty("applyDefaults") Boolean applyDefaults) {
448+
449+
/**
450+
* Creates a Form with default settings (no applyDefaults).
451+
*/
452+
public Form() {
453+
this(null);
454+
}
441455
}
442456

443457
/**
444458
* Marker record indicating support for URL-based elicitation mode.
445459
*/
446460
@JsonInclude(JsonInclude.Include.NON_ABSENT)
461+
@JsonIgnoreProperties(ignoreUnknown = true)
447462
public record Url() {
448463
}
449464

@@ -507,6 +522,20 @@ public Builder elicitation(boolean form, boolean url) {
507522
return this;
508523
}
509524

525+
/**
526+
* Enables elicitation capability with form mode and applyDefaults setting.
527+
* @param form whether to support form-based elicitation
528+
* @param url whether to support URL-based elicitation
529+
* @param applyDefaults whether the client should apply schema defaults to
530+
* elicitation results
531+
* @return this builder
532+
*/
533+
public Builder elicitation(boolean form, boolean url, boolean applyDefaults) {
534+
this.elicitation = new Elicitation(form ? new Elicitation.Form(applyDefaults) : null,
535+
url ? new Elicitation.Url() : null);
536+
return this;
537+
}
538+
510539
public ClientCapabilities build() {
511540
return new ClientCapabilities(experimental, roots, sampling, elicitation);
512541
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/*
2+
* Copyright 2024-2024 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.client;
6+
7+
import java.util.HashMap;
8+
import java.util.List;
9+
import java.util.Map;
10+
11+
import org.junit.jupiter.api.Test;
12+
13+
import static org.assertj.core.api.Assertions.assertThat;
14+
15+
/**
16+
* Tests for {@link McpAsyncClient#applyElicitationDefaults(Map, Map)}.
17+
*
18+
* Verifies that the client-side default application logic correctly fills in missing
19+
* fields from schema defaults, matching the behavior specified in SEP-1034.
20+
*/
21+
class McpAsyncClientElicitationDefaultsTests {
22+
23+
@Test
24+
void appliesStringDefault() {
25+
Map<String, Object> schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest")));
26+
27+
Map<String, Object> content = new HashMap<>();
28+
McpAsyncClient.applyElicitationDefaults(schema, content);
29+
30+
assertThat(content).containsEntry("name", "Guest");
31+
}
32+
33+
@Test
34+
void appliesNumberDefault() {
35+
Map<String, Object> schema = Map.of("properties", Map.of("age", Map.of("type", "integer", "default", 18)));
36+
37+
Map<String, Object> content = new HashMap<>();
38+
McpAsyncClient.applyElicitationDefaults(schema, content);
39+
40+
assertThat(content).containsEntry("age", 18);
41+
}
42+
43+
@Test
44+
void appliesBooleanDefault() {
45+
Map<String, Object> schema = Map.of("properties",
46+
Map.of("subscribe", Map.of("type", "boolean", "default", true)));
47+
48+
Map<String, Object> content = new HashMap<>();
49+
McpAsyncClient.applyElicitationDefaults(schema, content);
50+
51+
assertThat(content).containsEntry("subscribe", true);
52+
}
53+
54+
@Test
55+
void appliesEnumDefault() {
56+
Map<String, Object> schema = Map.of("properties",
57+
Map.of("color", Map.of("type", "string", "enum", List.of("red", "green"), "default", "green")));
58+
59+
Map<String, Object> content = new HashMap<>();
60+
McpAsyncClient.applyElicitationDefaults(schema, content);
61+
62+
assertThat(content).containsEntry("color", "green");
63+
}
64+
65+
@Test
66+
void doesNotOverrideExistingValues() {
67+
Map<String, Object> schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest")));
68+
69+
Map<String, Object> content = new HashMap<>();
70+
content.put("name", "Alice");
71+
McpAsyncClient.applyElicitationDefaults(schema, content);
72+
73+
assertThat(content).containsEntry("name", "Alice");
74+
}
75+
76+
@Test
77+
void skipsPropertiesWithoutDefault() {
78+
Map<String, Object> schema = Map.of("properties", Map.of("email", Map.of("type", "string")));
79+
80+
Map<String, Object> content = new HashMap<>();
81+
McpAsyncClient.applyElicitationDefaults(schema, content);
82+
83+
assertThat(content).doesNotContainKey("email");
84+
}
85+
86+
@Test
87+
void appliesMultipleDefaults() {
88+
Map<String, Object> schema = Map.of("properties",
89+
Map.of("name", Map.of("type", "string", "default", "Guest"), "age",
90+
Map.of("type", "integer", "default", 18), "subscribe",
91+
Map.of("type", "boolean", "default", true), "color",
92+
Map.of("type", "string", "enum", List.of("red", "green"), "default", "green")));
93+
94+
Map<String, Object> content = new HashMap<>();
95+
McpAsyncClient.applyElicitationDefaults(schema, content);
96+
97+
assertThat(content).containsEntry("name", "Guest")
98+
.containsEntry("age", 18)
99+
.containsEntry("subscribe", true)
100+
.containsEntry("color", "green");
101+
}
102+
103+
@Test
104+
void handlesNullSchema() {
105+
Map<String, Object> content = new HashMap<>();
106+
McpAsyncClient.applyElicitationDefaults(null, content);
107+
108+
assertThat(content).isEmpty();
109+
}
110+
111+
@Test
112+
void handlesNullContent() {
113+
Map<String, Object> schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest")));
114+
115+
// Should not throw
116+
McpAsyncClient.applyElicitationDefaults(schema, null);
117+
}
118+
119+
@Test
120+
void handlesSchemaWithoutProperties() {
121+
Map<String, Object> schema = Map.of("type", "object");
122+
123+
Map<String, Object> content = new HashMap<>();
124+
McpAsyncClient.applyElicitationDefaults(schema, content);
125+
126+
assertThat(content).isEmpty();
127+
}
128+
129+
@Test
130+
void appliesDefaultsOnlyToMissingFields() {
131+
Map<String, Object> schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest"),
132+
"age", Map.of("type", "integer", "default", 18)));
133+
134+
Map<String, Object> content = new HashMap<>();
135+
content.put("name", "John");
136+
McpAsyncClient.applyElicitationDefaults(schema, content);
137+
138+
assertThat(content).containsEntry("name", "John").containsEntry("age", 18);
139+
}
140+
141+
@Test
142+
void appliesFloatingPointDefault() {
143+
Map<String, Object> schema = Map.of("properties", Map.of("score", Map.of("type", "number", "default", 95.5)));
144+
145+
Map<String, Object> content = new HashMap<>();
146+
McpAsyncClient.applyElicitationDefaults(schema, content);
147+
148+
assertThat(content).containsEntry("score", 95.5);
149+
}
150+
151+
}

mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,76 @@ void testCreateElicitationSuccess(String clientType) {
446446
}
447447
}
448448

449+
@ParameterizedTest(name = "{0} : {displayName} ")
450+
@MethodSource("clientsForTesting")
451+
void testCreateElicitationWithApplyDefaults(String clientType) {
452+
453+
var clientBuilder = clientBuilders.get(clientType);
454+
455+
// Client handler returns empty content — SDK should apply defaults
456+
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler = request -> {
457+
assertThat(request.message()).isNotEmpty();
458+
assertThat(request.requestedSchema()).isNotNull();
459+
// Return accept with empty content, simulating a user who didn't fill
460+
// anything
461+
return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, new java.util.HashMap<>());
462+
};
463+
464+
CallToolResult callResponse = McpSchema.CallToolResult.builder()
465+
.addContent(new McpSchema.TextContent("CALL RESPONSE"))
466+
.build();
467+
468+
AtomicReference<McpSchema.ElicitResult> elicitResultRef = new AtomicReference<>();
469+
470+
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
471+
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
472+
.callHandler((exchange, request) -> {
473+
474+
var elicitationRequest = McpSchema.ElicitRequest.builder()
475+
.message("Provide your preferences")
476+
.requestedSchema(Map.of("type", "object", "properties",
477+
Map.of("nickname", Map.of("type", "string", "default", "Guest"), "age",
478+
Map.of("type", "integer", "default", 18), "subscribe",
479+
Map.of("type", "boolean", "default", true), "color",
480+
Map.of("type", "string", "enum", java.util.List.of("red", "green"), "default",
481+
"green")),
482+
"required", java.util.List.of("nickname", "age", "subscribe", "color")))
483+
.build();
484+
485+
return exchange.createElicitation(elicitationRequest)
486+
.doOnNext(elicitResultRef::set)
487+
.thenReturn(callResponse);
488+
})
489+
.build();
490+
491+
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
492+
493+
// Enable applyDefaults via the capability
494+
try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
495+
.capabilities(ClientCapabilities.builder().elicitation(true, false, true).build())
496+
.elicitation(elicitationHandler)
497+
.build()) {
498+
499+
InitializeResult initResult = mcpClient.initialize();
500+
assertThat(initResult).isNotNull();
501+
502+
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
503+
504+
assertThat(response).isNotNull();
505+
assertWith(elicitResultRef.get(), result -> {
506+
assertThat(result).isNotNull();
507+
assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT);
508+
assertThat(result.content()).containsEntry("nickname", "Guest");
509+
assertThat(result.content()).containsEntry("age", 18);
510+
assertThat(result.content()).containsEntry("subscribe", true);
511+
assertThat(result.content()).containsEntry("color", "green");
512+
});
513+
}
514+
finally {
515+
mcpServer.closeGracefully().block();
516+
}
517+
}
518+
449519
@ParameterizedTest(name = "{0} : {displayName} ")
450520
@MethodSource("clientsForTesting")
451521
void testCreateElicitationWithRequestTimeoutSuccess(String clientType) {

0 commit comments

Comments
 (0)