Skip to content

Commit 0554ae2

Browse files
author
Sainath Reddy Bobbala
committed
feat: Add SEP-1034 elicitation schema default values
Add support for the applyDefaults capability in elicitation form mode, allowing clients to indicate they will apply schema-defined default values to elicitation results before returning them to the server. Changes: - Add applyDefaults field to Elicitation.Form capability record - Add elicitation(boolean, boolean, boolean) builder overload - Implement default value application in McpAsyncClient - Add comprehensive tests for default value merging behavior - Add serialization round-trip tests for the new capability Ref #908
1 parent 5895b2e commit 0554ae2

5 files changed

Lines changed: 477 additions & 15 deletions

File tree

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

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
import java.util.concurrent.ConcurrentHashMap;
1616
import java.util.function.Function;
1717

18+
import org.slf4j.Logger;
19+
import org.slf4j.LoggerFactory;
20+
1821
import io.modelcontextprotocol.client.LifecycleInitializer.Initialization;
1922
import io.modelcontextprotocol.json.TypeRef;
2023
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
@@ -30,16 +33,14 @@
3033
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
3134
import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
3235
import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
33-
import io.modelcontextprotocol.util.ToolNameValidator;
3436
import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult;
3537
import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
3638
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
3739
import io.modelcontextprotocol.spec.McpSchema.PaginatedRequest;
3840
import io.modelcontextprotocol.spec.McpSchema.Root;
3941
import io.modelcontextprotocol.util.Assert;
42+
import io.modelcontextprotocol.util.ToolNameValidator;
4043
import io.modelcontextprotocol.util.Utils;
41-
import org.slf4j.Logger;
42-
import org.slf4j.LoggerFactory;
4344
import reactor.core.publisher.Flux;
4445
import reactor.core.publisher.Mono;
4546

@@ -561,10 +562,65 @@ private RequestHandler<ElicitResult> elicitationCreateHandler() {
561562
ElicitRequest request = transport.unmarshalFrom(params, new TypeRef<>() {
562563
});
563564

564-
return this.elicitationHandler.apply(request);
565+
return this.elicitationHandler.apply(request).map(result -> {
566+
// Apply defaults from schema when applyDefaults is enabled
567+
if (result.action() == ElicitResult.Action.ACCEPT && result.content() != null
568+
&& shouldApplyElicitationDefaults()) {
569+
Map<String, Object> merged = new HashMap<>(result.content());
570+
applyElicitationDefaults(request.requestedSchema(), merged);
571+
return new ElicitResult(result.action(), merged, result.meta());
572+
}
573+
return result;
574+
});
565575
};
566576
}
567577

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

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

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,20 @@
1010
import java.util.List;
1111
import java.util.Map;
1212

13+
import org.slf4j.Logger;
14+
import org.slf4j.LoggerFactory;
15+
1316
import com.fasterxml.jackson.annotation.JsonCreator;
1417
import com.fasterxml.jackson.annotation.JsonIgnore;
1518
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
1619
import com.fasterxml.jackson.annotation.JsonInclude;
1720
import com.fasterxml.jackson.annotation.JsonProperty;
1821
import com.fasterxml.jackson.annotation.JsonSubTypes;
1922
import com.fasterxml.jackson.annotation.JsonTypeInfo;
23+
2024
import io.modelcontextprotocol.json.McpJsonMapper;
2125
import io.modelcontextprotocol.json.TypeRef;
2226
import io.modelcontextprotocol.util.Assert;
23-
import org.slf4j.Logger;
24-
import org.slf4j.LoggerFactory;
2527

2628
/**
2729
* Based on the <a href="http://www.jsonrpc.org/specification">JSON-RPC 2.0
@@ -426,11 +428,23 @@ public record Sampling() {
426428
public record Elicitation(@JsonProperty("form") Form form, @JsonProperty("url") Url url) {
427429

428430
/**
429-
* Marker record indicating support for form-based elicitation mode.
431+
* Record indicating support for form-based elicitation mode.
432+
*
433+
* @param applyDefaults Whether the client should apply default values from
434+
* the schema to the elicitation result content when fields are missing. When
435+
* true, the SDK will automatically fill in missing fields with their
436+
* schema-defined defaults before returning the result to the server.
430437
*/
431438
@JsonInclude(JsonInclude.Include.NON_ABSENT)
432439
@JsonIgnoreProperties(ignoreUnknown = true)
433-
public record Form() {
440+
public record Form(@JsonProperty("applyDefaults") Boolean applyDefaults) {
441+
442+
/**
443+
* Creates a Form with default settings (no applyDefaults).
444+
*/
445+
public Form() {
446+
this(null);
447+
}
434448
}
435449

436450
/**
@@ -501,6 +515,31 @@ public Builder elicitation(boolean form, boolean url) {
501515
return this;
502516
}
503517

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

0 commit comments

Comments
 (0)