Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
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;
Expand All @@ -30,16 +33,14 @@
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;

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

return this.elicitationHandler.apply(request);
return this.elicitationHandler.apply(request).map(result -> {
// Apply defaults from schema when applyDefaults is enabled
if (result.action() == ElicitResult.Action.ACCEPT && result.content() != null
&& shouldApplyElicitationDefaults()) {
Map<String, Object> merged = new HashMap<>(result.content());
applyElicitationDefaults(request.requestedSchema(), merged);
return new ElicitResult(result.action(), merged, result.meta());
}
return result;
});
};
}

/**
* Checks whether the client is configured to apply elicitation defaults.
* @return true if the client capabilities indicate that defaults should be applied
*/
private boolean shouldApplyElicitationDefaults() {
if (this.clientCapabilities.elicitation() == null) {
return false;
}
McpSchema.ClientCapabilities.Elicitation.Form form = this.clientCapabilities.elicitation().form();
return form != null && Boolean.TRUE.equals(form.applyDefaults());
}

/**
* Applies default values from the elicitation schema to the result content. For each
* property in the schema that has a "default" value, if the corresponding key is
* missing from the content map, the default value is inserted.
* @param schema the requestedSchema from the ElicitRequest
* @param content the mutable content map from the ElicitResult
*/
@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
// --------------------------
Expand Down
50 changes: 46 additions & 4 deletions mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,19 @@
import java.util.List;
import java.util.Map;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;

import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.util.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Based on the <a href="http://www.jsonrpc.org/specification">JSON-RPC 2.0
Expand Down Expand Up @@ -431,19 +433,34 @@ public record Sampling() {
* @param url support for out-of-band URL-based elicitation
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonIgnoreProperties(ignoreUnknown = true)
public record Elicitation(@JsonProperty("form") Form form, @JsonProperty("url") Url url) {

/**
* Marker record indicating support for form-based elicitation mode.
* Record indicating support for form-based elicitation mode.
*
* @param applyDefaults Whether the client should apply default values from
* the schema to the elicitation result content when fields are missing. When
* true, the SDK will automatically fill in missing fields with their
* schema-defined defaults before returning the result to the server.
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record Form() {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Form(@JsonProperty("applyDefaults") Boolean applyDefaults) {

/**
* Creates a Form with default settings (no applyDefaults).
*/
public Form() {
this(null);
}
}

/**
* Marker record indicating support for URL-based elicitation mode.
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonIgnoreProperties(ignoreUnknown = true)
public record Url() {
}

Expand Down Expand Up @@ -507,6 +524,31 @@ public Builder elicitation(boolean form, boolean url) {
return this;
}

/**
* Enables elicitation capability with form mode and applyDefaults setting.
* <p>
* Note: {@code applyDefaults} is an SDK-level behavior flag that controls
* whether the client automatically fills in missing fields from schema
* defaults. It is serialized as part of the capabilities sent to the server
* during initialization, consistent with the TypeScript SDK behavior. Servers
* should tolerate unknown capability fields per the MCP specification.
* @param form whether to support form-based elicitation
* @param url whether to support URL-based elicitation
* @param applyDefaults whether the client should apply schema defaults to
* elicitation results. Requires {@code form} to be {@code true}.
* @return this builder
* @throws IllegalArgumentException if {@code applyDefaults} is {@code true}
* but {@code form} is {@code false}
*/
public Builder elicitation(boolean form, boolean url, boolean applyDefaults) {
if (!form && applyDefaults) {
throw new IllegalArgumentException("applyDefaults requires form to be true");
}
this.elicitation = new Elicitation(form ? new Elicitation.Form(applyDefaults) : null,
url ? new Elicitation.Url() : null);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applyDefaults is a client-side SDK behavior flag, but because it lives inside ClientCapabilities.Elicitation.Form, it gets serialized and sent to the server in the initialize request. A strict MCP server that validates capabilities against the protocol schema will receive an unknown field it never declared. This could break interoperability.

Consider keeping applyDefaults purely in the SDK layer (e.g., a separate McpClientOptions / builder flag) rather than embedding it in the protocol capabilities record. Alternatively, document explicitly that this is an SDK extension field and that servers must tolerate unknown capability fields.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the interoperability concern.

This follows the TypeScript SDK's design — applyDefaults is defined inside FormElicitationCapabilitySchema and gets serialized to the server during initialization:
https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/core/src/types/schemas.ts#L312

The Java SDK already uses @JsonIgnoreProperties(ignoreUnknown = true) on capability records, so servers built with this SDK will tolerate it. I've added Javadoc documenting this as an SDK-level behavior flag, consistent with the TypeScript reference implementation.

That said, if the maintainers prefer keeping it outside the protocol record (separate builder flag), happy to refactor.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new builder overload elicitation(boolean form, boolean url, boolean applyDefaults) silently drops applyDefaults when form == false. A caller writing elicitation(false, true, true) gets a capability object where applyDefaults is never set, with no warning. Since shouldApplyElicitationDefaults() then returns false, defaults are never applied.

At minimum, add a guard:

if (!form && applyDefaults) {
    throw new IllegalArgumentException("applyDefaults requires form to be true");
}

Or document the behavior clearly in the Javadoc.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Added an IllegalArgumentException guard — elicitation(false, true, true) now throws with "applyDefaults requires form to be true".

return this;
}

public ClientCapabilities build() {
return new ClientCapabilities(experimental, roots, sampling, elicitation);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* Copyright 2024-2025 the original author or authors.
*/

package io.modelcontextprotocol.client;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;
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);
}

}
Loading