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
21 changes: 21 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,27 @@ var client = McpClient.sync(transport)
.build();
```

### Required Tool Result Content

By default, the SDK accepts a `tools/call` result with missing or null `content` and
replaces it with an empty list. To reject these responses before that substitution,
enable content validation on either the synchronous or asynchronous client builder:

```java
var client = McpClient.async(transport)
.validateCallToolResultContent(true)
.build();
```

With this option enabled, `callTool` fails with `IllegalArgumentException` when
`content` is missing, null, or not an array, including when `isError` is true.
An explicit `content: []` remains valid. This validates the required content field;
it is separate from validating `structuredContent` against a tool's `outputSchema`.
It does not enable strict validation for other MCP messages.

A rejected response does not imply that the server rolled back the tool's effects.
The SDK does not retry the tool call because content validation failed.

### Pagination

`listTools`, `listResources`, `listResourceTemplates`, and `listPrompts` all accept an optional opaque `cursor` string, and their results carry a `nextCursor` that is non-null while more pages remain. Loop until `nextCursor` is `null` to collect every page:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ public class McpAsyncClient {

private final boolean applyElicitationDefaults;

private final boolean validateCallToolResultContent;

/**
* Create a new McpAsyncClient with the given transport and session request-response
* timeout.
Expand All @@ -210,6 +212,7 @@ public class McpAsyncClient {
this.toolsOutputSchemaCache = new ConcurrentHashMap<>();
this.enableCallToolSchemaCaching = features.enableCallToolSchemaCaching();
this.applyElicitationDefaults = features.applyElicitationDefaults();
this.validateCallToolResultContent = features.validateCallToolResultContent();

// Request Handlers
Map<String, RequestHandler<?>> requestHandlers = new HashMap<>();
Expand Down Expand Up @@ -672,6 +675,9 @@ static void applyElicitationDefaults(Map<String, Object> schema, Map<String, Obj
private static final TypeRef<McpSchema.CallToolResult> CALL_TOOL_RESULT_TYPE_REF = new TypeRef<>() {
};

private static final TypeRef<Object> RAW_TOOL_RESULT_TYPE_REF = new TypeRef<>() {
};

private static final TypeRef<McpSchema.ListToolsResult> LIST_TOOLS_RESULT_TYPE_REF = new TypeRef<>() {
};

Expand All @@ -692,12 +698,26 @@ public Mono<McpSchema.CallToolResult> callTool(McpSchema.CallToolRequest callToo
return Mono.error(new IllegalStateException("Server does not provide tools capability"));
}

return init.mcpSession()
.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF)
.flatMap(result -> Mono.just(validateToolResult(callToolRequest.name(), result)));
Mono<McpSchema.CallToolResult> result = this.validateCallToolResultContent
? init.mcpSession()
.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, RAW_TOOL_RESULT_TYPE_REF)
.map(this::decodeToolResultWithContentValidation)
: init.mcpSession()
.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF);
return result.map(value -> validateToolResult(callToolRequest.name(), value));
});
}

private McpSchema.CallToolResult decodeToolResultWithContentValidation(Object result) {
// Check before CallToolResult.fromJson replaces missing or null content with [].
Object content = result instanceof Map<?, ?> fields ? fields.get("content") : null;
// Untyped JSON arrays may be represented as a List or a Java array by the mapper.
if (!(content instanceof List<?>) && !(content instanceof Object[])) {
throw new IllegalArgumentException("CallToolResult.content must be a non-null array");
}
return this.transport.unmarshalFrom(result, CALL_TOOL_RESULT_TYPE_REF);
}

private McpSchema.CallToolResult validateToolResult(String toolName, McpSchema.CallToolResult result) {

if (!this.enableCallToolSchemaCaching || result == null || result.isError() == Boolean.TRUE) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ class SyncSpec {

private boolean applyElicitationDefaults = false; // Default to false

private boolean validateCallToolResultContent;

private SyncSpec(McpClientTransport transport) {
Assert.notNull(transport, "Transport must not be null");
this.transport = transport;
Expand Down Expand Up @@ -545,6 +547,20 @@ public SyncSpec applyElicitationDefaults(boolean applyElicitationDefaults) {
return this;
}

/**
* Validate that a tools/call result contains a non-null content array before
* deserialization can substitute an empty list. Disabled by default for wire
* compatibility. An explicit empty array remains valid. This check is independent
* of tool output schema validation and also applies to results with isError=true.
* @param validateCallToolResultContent true to reject missing, null or non-array
* content with an IllegalArgumentException
* @return This builder instance for method chaining
*/
public SyncSpec validateCallToolResultContent(boolean validateCallToolResultContent) {
this.validateCallToolResultContent = validateCallToolResultContent;
return this;
}

/**
* Create an instance of {@link McpSyncClient} with the provided configurations or
* sensible defaults.
Expand All @@ -555,7 +571,8 @@ public McpSyncClient build() {
this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers,
this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers,
this.elicitationCompleteConsumers, this.samplingHandler, this.formElicitationHandler,
this.urlElicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults);
this.urlElicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults,
this.validateCallToolResultContent);

McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures);

Expand Down Expand Up @@ -637,6 +654,8 @@ class AsyncSpec {

private boolean applyElicitationDefaults = false; // Default to false

private boolean validateCallToolResultContent;

private AsyncSpec(McpClientTransport transport) {
Assert.notNull(transport, "Transport must not be null");
this.transport = transport;
Expand Down Expand Up @@ -966,6 +985,20 @@ public AsyncSpec applyElicitationDefaults(boolean applyElicitationDefaults) {
return this;
}

/**
* Validate that a tools/call result contains a non-null content array before
* deserialization can substitute an empty list. Disabled by default for wire
* compatibility. An explicit empty array remains valid. This check is independent
* of tool output schema validation and also applies to results with isError=true.
* @param validateCallToolResultContent true to reject missing, null or non-array
* content with an IllegalArgumentException
* @return This builder instance for method chaining
*/
public AsyncSpec validateCallToolResultContent(boolean validateCallToolResultContent) {
this.validateCallToolResultContent = validateCallToolResultContent;
return this;
}

/**
* Create an instance of {@link McpAsyncClient} with the provided configurations
* or sensible defaults.
Expand All @@ -980,8 +1013,8 @@ public McpAsyncClient build() {
this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers,
this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers,
this.elicitationCompleteConsumers, this.samplingHandler, this.formElicitationHandler,
this.urlElicitationHandler, this.enableCallToolSchemaCaching,
this.applyElicitationDefaults));
this.urlElicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults,
this.validateCallToolResultContent));
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ class McpClientFeatures {
* @param applyElicitationDefaults whether the client should fill in missing fields of
* an accepted {@code ElicitResult.content} with the {@code default} values declared
* in the {@code requestedSchema}.
* @param validateCallToolResultContent whether to validate required tool result
* content before deserialization.
*/
record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots, List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers,
Expand All @@ -78,7 +80,8 @@ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler,
Function<McpSchema.ElicitFormRequest, Mono<McpSchema.ElicitResult>> formElicitationHandler,
Function<McpSchema.ElicitUrlRequest, Mono<McpSchema.ElicitResult>> urlElicitationHandler,
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) {
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults,
boolean validateCallToolResultContent) {

/**
* Create an instance and validate the arguments.
Expand All @@ -95,6 +98,8 @@ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c
* @param applyElicitationDefaults whether the client should fill in missing
* fields of an accepted {@code ElicitResult.content} with the {@code default}
* values declared in the {@code requestedSchema}.
* @param validateCallToolResultContent whether to validate required tool result
* content before deserialization.
*/
public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots,
Expand All @@ -108,7 +113,8 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler,
Function<McpSchema.ElicitFormRequest, Mono<McpSchema.ElicitResult>> formElicitationHandler,
Function<McpSchema.ElicitUrlRequest, Mono<McpSchema.ElicitResult>> urlElicitationHandler,
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) {
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults,
boolean validateCallToolResultContent) {

Assert.notNull(clientInfo, "Client info must not be null");
this.clientInfo = clientInfo;
Expand All @@ -132,6 +138,7 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c
this.urlElicitationHandler = urlElicitationHandler;
this.enableCallToolSchemaCaching = enableCallToolSchemaCaching;
this.applyElicitationDefaults = applyElicitationDefaults;
this.validateCallToolResultContent = validateCallToolResultContent;
}

/**
Expand All @@ -148,7 +155,7 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c
Function<McpSchema.ElicitFormRequest, Mono<McpSchema.ElicitResult>> elicitationHandler) {
this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers,
resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), List.of(),
samplingHandler, elicitationHandler, null, false, false);
samplingHandler, elicitationHandler, null, false, false, false);
}

/**
Expand Down Expand Up @@ -223,7 +230,7 @@ public static Async fromSync(Sync syncSpec) {
toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers,
loggingConsumers, progressConsumers, elicitationCompleteConsumers, samplingHandler,
formElicitationHandler, urlElicitationHandler, syncSpec.enableCallToolSchemaCaching,
syncSpec.applyElicitationDefaults);
syncSpec.applyElicitationDefaults, syncSpec.validateCallToolResultContent);
}

}
Expand All @@ -246,6 +253,8 @@ public static Async fromSync(Sync syncSpec) {
* @param applyElicitationDefaults whether the client should fill in missing fields of
* an accepted {@code ElicitResult.content} with the {@code default} values declared
* in the {@code requestedSchema}.
* @param validateCallToolResultContent whether to validate required tool result
* content before deserialization.
*/
public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots, List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers,
Expand All @@ -258,7 +267,8 @@ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabili
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler,
Function<McpSchema.ElicitFormRequest, McpSchema.ElicitResult> formElicitationHandler,
Function<McpSchema.ElicitUrlRequest, McpSchema.ElicitResult> urlElicitationHandler,
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) {
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults,
boolean validateCallToolResultContent) {

/**
* Create an instance and validate the arguments.
Expand All @@ -277,6 +287,8 @@ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabili
* @param applyElicitationDefaults whether the client should fill in missing
* fields of an accepted {@code ElicitResult.content} with the {@code default}
* values declared in the {@code requestedSchema}.
* @param validateCallToolResultContent whether to validate required tool result
* content before deserialization.
*/
public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots, List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers,
Expand All @@ -289,7 +301,8 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler,
Function<McpSchema.ElicitFormRequest, McpSchema.ElicitResult> formElicitationHandler,
Function<McpSchema.ElicitUrlRequest, McpSchema.ElicitResult> urlElicitationHandler,
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) {
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults,
boolean validateCallToolResultContent) {

Assert.notNull(clientInfo, "Client info must not be null");
this.clientInfo = clientInfo;
Expand All @@ -313,6 +326,7 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl
this.urlElicitationHandler = urlElicitationHandler;
this.enableCallToolSchemaCaching = enableCallToolSchemaCaching;
this.applyElicitationDefaults = applyElicitationDefaults;
this.validateCallToolResultContent = validateCallToolResultContent;
}

/**
Expand All @@ -329,7 +343,7 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl
Function<McpSchema.ElicitUrlRequest, McpSchema.ElicitResult> urlElicitationHandler) {
this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers,
resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), List.of(),
samplingHandler, formElicitationHandler, urlElicitationHandler, false, false);
samplingHandler, formElicitationHandler, urlElicitationHandler, false, false, false);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright 2026 the original author or authors.
*/

package io.modelcontextprotocol.client;

import io.modelcontextprotocol.spec.McpSchema;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.assertj.core.api.Assertions.assertThat;

class McpClientFeaturesContentValidationTests {

private static final McpSchema.Implementation CLIENT_INFO = McpSchema.Implementation.builder("test", "1").build();

@ParameterizedTest
@ValueSource(booleans = { true, false })
void syncConversionPreservesContentValidation(boolean enabled) {
var sync = new McpClientFeatures.Sync(CLIENT_INFO, null, null, null, null, null, null, null, null, null, null,
null, null, true, true, enabled);
var async = McpClientFeatures.Async.fromSync(sync);
assertThat(async.validateCallToolResultContent()).isEqualTo(enabled);
assertThat(async.enableCallToolSchemaCaching()).isTrue();
assertThat(async.applyElicitationDefaults()).isTrue();
assertThat(async.clientCapabilities()).isEqualTo(sync.clientCapabilities());
}

@ParameterizedTest
@ValueSource(booleans = { true, false })
void asyncFeaturesPreserveContentValidation(boolean enabled) {
var features = new McpClientFeatures.Async(CLIENT_INFO, null, null, null, null, null, null, null, null, null,
null, null, null, true, true, enabled);
assertThat(features.validateCallToolResultContent()).isEqualTo(enabled);
assertThat(features.enableCallToolSchemaCaching()).isTrue();
assertThat(features.applyElicitationDefaults()).isTrue();
}

@Test
void legacySyncConstructorKeepsContentValidationDisabled() {
var sync = new McpClientFeatures.Sync(CLIENT_INFO, null, null, null, null, null, null, null, null, null, null);
assertThat(sync.validateCallToolResultContent()).isFalse();
assertThat(McpClientFeatures.Async.fromSync(sync).validateCallToolResultContent()).isFalse();
}

@Test
void legacyAsyncConstructorKeepsContentValidationDisabled() {
var async = new McpClientFeatures.Async(CLIENT_INFO, null, null, null, null, null, null, null, null, null);
assertThat(async.validateCallToolResultContent()).isFalse();
}

}
Loading