diff --git a/docs/client.md b/docs/client.md index c2ec9342d..4fc1a87ca 100644 --- a/docs/client.md +++ b/docs/client.md @@ -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: diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index 3509b760b..dc48d9ff9 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -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. @@ -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> requestHandlers = new HashMap<>(); @@ -672,6 +675,9 @@ static void applyElicitationDefaults(Map schema, Map CALL_TOOL_RESULT_TYPE_REF = new TypeRef<>() { }; + private static final TypeRef RAW_TOOL_RESULT_TYPE_REF = new TypeRef<>() { + }; + private static final TypeRef LIST_TOOLS_RESULT_TYPE_REF = new TypeRef<>() { }; @@ -692,12 +698,26 @@ public Mono 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 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) { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java index 328566a69..26776887c 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -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; @@ -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. @@ -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); @@ -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; @@ -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. @@ -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)); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java index f61123da0..0b21cffb7 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java @@ -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 roots, List, Mono>> toolsChangeConsumers, @@ -78,7 +80,8 @@ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c Function> samplingHandler, Function> formElicitationHandler, Function> urlElicitationHandler, - boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults, + boolean validateCallToolResultContent) { /** * Create an instance and validate the arguments. @@ -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 roots, @@ -108,7 +113,8 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c Function> samplingHandler, Function> formElicitationHandler, Function> urlElicitationHandler, - boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults, + boolean validateCallToolResultContent) { Assert.notNull(clientInfo, "Client info must not be null"); this.clientInfo = clientInfo; @@ -132,6 +138,7 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c this.urlElicitationHandler = urlElicitationHandler; this.enableCallToolSchemaCaching = enableCallToolSchemaCaching; this.applyElicitationDefaults = applyElicitationDefaults; + this.validateCallToolResultContent = validateCallToolResultContent; } /** @@ -148,7 +155,7 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c Function> 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); } /** @@ -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); } } @@ -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 roots, List>> toolsChangeConsumers, @@ -258,7 +267,8 @@ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabili Function samplingHandler, Function formElicitationHandler, Function urlElicitationHandler, - boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults, + boolean validateCallToolResultContent) { /** * Create an instance and validate the arguments. @@ -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 roots, List>> toolsChangeConsumers, @@ -289,7 +301,8 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl Function samplingHandler, Function formElicitationHandler, Function urlElicitationHandler, - boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults, + boolean validateCallToolResultContent) { Assert.notNull(clientInfo, "Client info must not be null"); this.clientInfo = clientInfo; @@ -313,6 +326,7 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl this.urlElicitationHandler = urlElicitationHandler; this.enableCallToolSchemaCaching = enableCallToolSchemaCaching; this.applyElicitationDefaults = applyElicitationDefaults; + this.validateCallToolResultContent = validateCallToolResultContent; } /** @@ -329,7 +343,7 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl Function 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); } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/McpClientFeaturesContentValidationTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/McpClientFeaturesContentValidationTests.java new file mode 100644 index 000000000..bc2e682ac --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/McpClientFeaturesContentValidationTests.java @@ -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(); + } + +} diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/CallToolResultArrayMappingTests.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/CallToolResultArrayMappingTests.java new file mode 100644 index 000000000..17dc8e894 --- /dev/null +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/CallToolResultArrayMappingTests.java @@ -0,0 +1,127 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package io.modelcontextprotocol.json; + +import java.io.IOException; +import java.time.Duration; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Stream; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CallToolResultArrayMappingTests { + + private final McpJsonMapper mapper = new JacksonMcpJsonMapper( + JsonMapper.builder().enable(DeserializationFeature.USE_JAVA_ARRAY_FOR_JSON_ARRAY).build()); + + static Stream results() { + return Stream.of(false, true) + .flatMap(sync -> Stream.of(Arguments.of(sync, "{\"content\":[],\"isError\":false}", true), + Arguments.of(sync, "{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}", true), + Arguments.of(sync, "{\"content\":[],\"structuredContent\":{\"answer\":42}}", true), + Arguments.of(sync, "{\"isError\":false}", false), Arguments.of(sync, "{\"content\":null}", false), + Arguments.of(sync, "{\"content\":\"invalid\"}", false))); + } + + @ParameterizedTest + @MethodSource("results") + void validatesContentWhenUntypedArraysAreJavaArrays(boolean sync, String json, boolean valid) throws IOException { + Object rawResult = mapper.readValue(json, Object.class); + if (valid) { + assertThat(((Map) rawResult).get("content")).isInstanceOf(Object[].class); + } + var transport = new ResultTransport(rawResult); + var request = McpSchema.CallToolRequest.builder("probe").build(); + if (sync) { + var client = McpClient.sync(transport).validateCallToolResultContent(true).build(); + try { + client.initialize(); + if (valid) { + assertThat(client.callTool(request)) + .isEqualTo(mapper.readValue(json, McpSchema.CallToolResult.class)); + } + else { + assertThatThrownBy(() -> client.callTool(request)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CallToolResult.content"); + } + } + finally { + client.close(); + } + } + else { + var client = McpClient.async(transport).validateCallToolResultContent(true).build(); + try { + client.initialize().block(Duration.ofSeconds(3)); + if (valid) { + assertThat(client.callTool(request).block(Duration.ofSeconds(3))) + .isEqualTo(mapper.readValue(json, McpSchema.CallToolResult.class)); + } + else { + assertThatThrownBy(() -> client.callTool(request).block(Duration.ofSeconds(3))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CallToolResult.content"); + } + } + finally { + client.closeGracefully().block(Duration.ofSeconds(3)); + } + } + } + + private class ResultTransport implements McpClientTransport { + + private final Object result; + + private Function, Mono> handler; + + ResultTransport(Object result) { + this.result = result; + } + + @Override + public Mono connect(Function, Mono> handler) { + this.handler = handler; + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (!(message instanceof McpSchema.JSONRPCRequest request)) { + return Mono.empty(); + } + Object response = McpSchema.METHOD_INITIALIZE.equals(request.method()) + ? Map.of("protocolVersion", "2025-11-25", "capabilities", Map.of("tools", Map.of()), "serverInfo", + Map.of("name", "test", "version", "1")) + : this.result; + return handler.apply(Mono.just(McpSchema.JSONRPCResponse.result(request.id(), response))).then(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return mapper.convertValue(data, typeRef); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + } + +} diff --git a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/CallToolResultArrayMappingTests.java b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/CallToolResultArrayMappingTests.java new file mode 100644 index 000000000..f885cd027 --- /dev/null +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/CallToolResultArrayMappingTests.java @@ -0,0 +1,127 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package io.modelcontextprotocol.json; + +import java.io.IOException; +import java.time.Duration; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Stream; + +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.json.JsonMapper; +import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CallToolResultArrayMappingTests { + + private final McpJsonMapper mapper = new JacksonMcpJsonMapper( + JsonMapper.builder().enable(DeserializationFeature.USE_JAVA_ARRAY_FOR_JSON_ARRAY).build()); + + static Stream results() { + return Stream.of(false, true) + .flatMap(sync -> Stream.of(Arguments.of(sync, "{\"content\":[],\"isError\":false}", true), + Arguments.of(sync, "{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}", true), + Arguments.of(sync, "{\"content\":[],\"structuredContent\":{\"answer\":42}}", true), + Arguments.of(sync, "{\"isError\":false}", false), Arguments.of(sync, "{\"content\":null}", false), + Arguments.of(sync, "{\"content\":\"invalid\"}", false))); + } + + @ParameterizedTest + @MethodSource("results") + void validatesContentWhenUntypedArraysAreJavaArrays(boolean sync, String json, boolean valid) throws IOException { + Object rawResult = mapper.readValue(json, Object.class); + if (valid) { + assertThat(((Map) rawResult).get("content")).isInstanceOf(Object[].class); + } + var transport = new ResultTransport(rawResult); + var request = McpSchema.CallToolRequest.builder("probe").build(); + if (sync) { + var client = McpClient.sync(transport).validateCallToolResultContent(true).build(); + try { + client.initialize(); + if (valid) { + assertThat(client.callTool(request)) + .isEqualTo(mapper.readValue(json, McpSchema.CallToolResult.class)); + } + else { + assertThatThrownBy(() -> client.callTool(request)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CallToolResult.content"); + } + } + finally { + client.close(); + } + } + else { + var client = McpClient.async(transport).validateCallToolResultContent(true).build(); + try { + client.initialize().block(Duration.ofSeconds(3)); + if (valid) { + assertThat(client.callTool(request).block(Duration.ofSeconds(3))) + .isEqualTo(mapper.readValue(json, McpSchema.CallToolResult.class)); + } + else { + assertThatThrownBy(() -> client.callTool(request).block(Duration.ofSeconds(3))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CallToolResult.content"); + } + } + finally { + client.closeGracefully().block(Duration.ofSeconds(3)); + } + } + } + + private class ResultTransport implements McpClientTransport { + + private final Object result; + + private Function, Mono> handler; + + ResultTransport(Object result) { + this.result = result; + } + + @Override + public Mono connect(Function, Mono> handler) { + this.handler = handler; + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (!(message instanceof McpSchema.JSONRPCRequest request)) { + return Mono.empty(); + } + Object response = McpSchema.METHOD_INITIALIZE.equals(request.method()) + ? Map.of("protocolVersion", "2025-11-25", "capabilities", Map.of("tools", Map.of()), "serverInfo", + Map.of("name", "test", "version", "1")) + : this.result; + return handler.apply(Mono.just(McpSchema.JSONRPCResponse.result(request.id(), response))).then(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return mapper.convertValue(data, typeRef); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/CallToolResultContentValidationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/CallToolResultContentValidationTests.java new file mode 100644 index 000000000..7c8081f98 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/CallToolResultContentValidationTests.java @@ -0,0 +1,249 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package io.modelcontextprotocol.client; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Timeout(15) +class CallToolResultContentValidationTests { + + private static final McpSchema.CallToolRequest REQUEST = McpSchema.CallToolRequest.builder("probe").build(); + + private HttpServer server; + + private HttpClientStreamableHttpTransport transport; + + private volatile String toolResult = "{\"isError\":false}"; + + private volatile boolean outputSchema; + + private volatile boolean rpcError; + + private final AtomicInteger toolCalls = new AtomicInteger(); + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.createContext("/mcp", this::respond); + server.start(); + transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + server.getAddress().getPort()) + .openConnectionOnStartup(false) + .resumableStreams(false) + .build(); + } + + @AfterEach + void stopServer() { + transport.closeGracefully().block(Duration.ofSeconds(2)); + server.stop(0); + } + + @ParameterizedTest + @ValueSource(strings = { "{\"isError\":false}", "{\"content\":null,\"isError\":false}", "{\"isError\":true}", + "{\"content\":{}}", "{\"content\":\"text\"}", "[]" }) + void asyncClientRejectsMissingOrNullContent(String result) { + toolResult = result; + var client = McpClient.async(transport).validateCallToolResultContent(true).build(); + try { + StepVerifier.create(client.initialize().then(client.callTool(REQUEST))) + .expectErrorSatisfies(error -> assertThat(error).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CallToolResult.content")) + .verify(Duration.ofSeconds(3)); + assertThat(toolCalls).hasValue(1); + toolResult = "{\"content\":[],\"isError\":false}"; + StepVerifier.create(client.callTool(REQUEST)) + .assertNext(value -> assertThat(value.content()).isEmpty()) + .verifyComplete(); + assertThat(toolCalls).hasValue(2); + } + finally { + client.close(); + } + } + + @ParameterizedTest + @ValueSource(strings = { "{\"isError\":false}", "{\"content\":null,\"isError\":false}", "{\"isError\":true}", + "{\"content\":{}}", "{\"content\":\"text\"}", "[]" }) + void syncClientRejectsMissingOrNullContent(String result) { + toolResult = result; + var client = McpClient.sync(transport).validateCallToolResultContent(true).build(); + try { + client.initialize(); + assertThatThrownBy(() -> client.callTool(REQUEST)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CallToolResult.content"); + assertThat(toolCalls).hasValue(1); + } + finally { + client.close(); + } + } + + @ParameterizedTest + @ValueSource(strings = { "{\"isError\":false}", "{\"content\":null,\"isError\":false}" }) + void defaultClientStillAcceptsMissingOrNullContent(String result) { + toolResult = result; + var client = McpClient.async(transport).build(); + try { + StepVerifier.create(client.initialize().then(client.callTool(REQUEST))).assertNext(value -> { + assertThat(value.content()).isEmpty(); + assertThat(value.isError()).isFalse(); + }).verifyComplete(); + } + finally { + client.close(); + } + } + + @ParameterizedTest + @ValueSource( + strings = { "{\"content\":[]}", "{\"content\":[],\"isError\":false}", "{\"content\":[],\"isError\":true}", + "{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],\"isError\":false}", + "{\"content\":[],\"structuredContent\":{\"answer\":42},\"futureField\":true}" }) + void strictAsyncClientPreservesValidResults(String result) throws IOException { + toolResult = result; + var expected = McpJsonDefaults.getMapper().readValue(result, McpSchema.CallToolResult.class); + var client = McpClient.async(transport).validateCallToolResultContent(true).build(); + try { + StepVerifier.create(client.initialize().then(client.listTools()).then(client.callTool(REQUEST))) + .assertNext(value -> assertThat(value).isEqualTo(expected)) + .verifyComplete(); + } + finally { + client.close(); + } + } + + @Test + void strictSyncClientAcceptsExplicitEmptyContent() { + toolResult = "{\"content\":[],\"isError\":false}"; + var client = McpClient.sync(transport).validateCallToolResultContent(true).build(); + try { + client.initialize(); + assertThat(client.callTool(REQUEST).content()).isEmpty(); + } + finally { + client.close(); + } + } + + private void respond(HttpExchange exchange) throws IOException { + try (exchange) { + if (!"POST".equals(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1); + return; + } + var request = McpJsonDefaults.getMapper().readValue(exchange.getRequestBody().readAllBytes(), Map.class); + if (!request.containsKey("id")) { + exchange.sendResponseHeaders(202, -1); + return; + } + String result = switch ((String) request.get("method")) { + case "initialize" -> """ + {"protocolVersion":"2025-11-25","capabilities":{"tools":{}}, + "serverInfo":{"name":"test","version":"1"}} + """; + case "tools/list" -> outputSchema + ? """ + {"tools":[{"name":"probe","inputSchema":{"type":"object"}, + "outputSchema":{"type":"object","properties":{"answer":{"type":"integer"}},"required":["answer"]}}]} + """ + : """ + {"tools":[{"name":"probe","inputSchema":{"type":"object"}}]} + """; + case "tools/call" -> { + toolCalls.incrementAndGet(); + yield toolResult; + } + default -> "{}"; + }; + String id = McpJsonDefaults.getMapper().writeValueAsString(request.get("id")); + String payload = rpcError && "tools/call".equals(request.get("method")) + ? "\"error\":{\"code\":-32602,\"message\":\"bad arguments\"}" : "\"result\":" + result; + byte[] body = ("{\"jsonrpc\":\"2.0\",\"id\":" + id + "," + payload + "}").getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } + } + + @ParameterizedTest + @ValueSource(strings = { "{\"isError\":false}", "{\"content\":null,\"isError\":false}" }) + void syncClientRemainsLenientWhenValidationIsDisabled(String result) { + toolResult = result; + var client = McpClient.sync(transport).validateCallToolResultContent(false).build(); + try { + client.initialize(); + assertThat(client.callTool(REQUEST).content()).isEmpty(); + } + finally { + client.close(); + } + } + + @Test + void strictClientPreservesJsonRpcErrors() { + rpcError = true; + var client = McpClient.async(transport).validateCallToolResultContent(true).build(); + try { + StepVerifier.create(client.initialize().then(client.callTool(REQUEST))).verifyError(McpError.class); + assertThat(toolCalls).hasValue(1); + } + finally { + client.close(); + } + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void strictContentValidationStillAppliesOutputSchema(boolean validOutput) { + outputSchema = true; + toolResult = validOutput ? "{\"content\":[],\"structuredContent\":{\"answer\":42}}" + : "{\"content\":[],\"structuredContent\":{\"answer\":\"wrong type\"}}"; + var client = McpClient.async(transport) + .validateCallToolResultContent(true) + .enableCallToolSchemaCaching(true) + .build(); + try { + var verifier = StepVerifier + .create(client.initialize().then(client.listTools()).then(client.callTool(REQUEST))); + if (validOutput) { + verifier.assertNext(result -> assertThat(result.structuredContent()).isEqualTo(Map.of("answer", 42))) + .verifyComplete(); + } + else { + verifier + .expectErrorSatisfies(error -> assertThat(error).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Tool call result validation failed")) + .verify(); + } + } + finally { + client.close(); + } + } + +}