diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java index e313454bd..ee5f3fd17 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java @@ -4,6 +4,7 @@ package io.modelcontextprotocol.server; +import java.util.HashMap; import java.util.List; import io.modelcontextprotocol.spec.McpError; @@ -17,6 +18,7 @@ import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; import io.modelcontextprotocol.spec.McpSchema.Tool; import io.modelcontextprotocol.spec.McpServerTransportProvider; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -125,7 +127,7 @@ void testAddTool() { @Test void testAddDuplicateTool() { - Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); + Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema, emptyJsonSchema); var mcpSyncServer = McpServer.sync(createMcpTransportProvider()) .serverInfo("test-server", "1.0.0") @@ -134,7 +136,7 @@ void testAddDuplicateTool() { .build(); assertThatThrownBy(() -> mcpSyncServer.addTool(new McpServerFeatures.SyncToolSpecification(duplicateTool, - (exchange, args) -> new CallToolResult(List.of(), false)))) + (exchange, args) -> new CallToolResult(List.of(), false, new HashMap())))) .isInstanceOf(McpError.class) .hasMessage("Tool with name '" + TEST_TOOL_NAME + "' already exists"); diff --git a/mcp/pom.xml b/mcp/pom.xml index 773432827..7f3a43a20 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -202,6 +202,13 @@ test + + + com.networknt + json-schema-validator + 1.5.7 + + diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index 8f0433eb1..8eefc2454 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -8,12 +8,14 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import java.util.function.Supplier; import com.fasterxml.jackson.core.type.TypeReference; + import io.modelcontextprotocol.spec.McpClientSession; import io.modelcontextprotocol.spec.McpClientSession.NotificationHandler; import io.modelcontextprotocol.spec.McpClientSession.RequestHandler; @@ -27,6 +29,7 @@ import io.modelcontextprotocol.spec.McpSchema.ElicitResult; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.spec.McpSchema.JsonSchema; import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult; import io.modelcontextprotocol.spec.McpSchema.LoggingLevel; import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification; @@ -35,8 +38,11 @@ import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException; import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.Utils; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; + +import io.modelcontextprotocol.server.McpServerFeatures; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; @@ -118,6 +124,11 @@ public class McpAsyncClient { */ private final McpSchema.Implementation clientInfo; + /** + * Cached tool output schemas. + */ + private final ConcurrentHashMap> toolsOutputSchemaCache; + /** * Roots define the boundaries of where servers can operate within the filesystem, * allowing them to understand which directories and files they have access to. @@ -181,6 +192,7 @@ public class McpAsyncClient { this.transport = transport; this.roots = new ConcurrentHashMap<>(features.roots()); this.initializationTimeout = initializationTimeout; + this.toolsOutputSchemaCache = new ConcurrentHashMap<>(); // Request Handlers Map> requestHandlers = new HashMap<>(); @@ -331,6 +343,14 @@ public McpSchema.Implementation getClientInfo() { return this.clientInfo; } + /** + * Get the cached tool output schemas. + * @return The cached tool output schemas + */ + public ConcurrentHashMap> getToolsOutputSchemaCache() { + return this.toolsOutputSchemaCache; + } + /** * Closes the client connection immediately. */ @@ -650,8 +670,13 @@ public Mono callTool(McpSchema.CallToolRequest callToo if (init.get().capabilities().tools() == null) { return Mono.error(new McpError("Server does not provide tools capability")); } - return init.mcpSession() - .sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF); + // Refresh tool output schema cache, if necessary, prior to making tool call + Mono refreshCacheMono = Mono.empty(); + if (!this.toolsOutputSchemaCache.containsKey(callToolRequest.name())) { + refreshCacheMono = refreshToolOutputSchemaCache(); + } + return refreshCacheMono.then(init.mcpSession() + .sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF)); }); } @@ -675,7 +700,33 @@ public Mono listTools(String cursor) { } return init.mcpSession() .sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor), - LIST_TOOLS_RESULT_TYPE_REF); + LIST_TOOLS_RESULT_TYPE_REF) + .doOnNext(result -> { + // Cache tools output schema + if (result.tools() != null) { + // Cache tools output schema + result.tools() + .forEach(tool -> this.toolsOutputSchemaCache.put(tool.name(), + Optional.ofNullable(tool.outputSchema()))); + } + }); + }); + } + + /** + * Refreshes the tool output schema cache by fetching all tools from the server. + * @return A Mono that completes when all tool output schemas have been cached + */ + private Mono refreshToolOutputSchemaCache() { + return this.withSession("refreshing tool output schema cache", init -> { + + // Use expand operator to handle pagination in a reactive way + return this.listTools(null).expand(result -> { + if (result.nextCursor() != null) { + return this.listTools(result.nextCursor()); + } + return Mono.empty(); + }).then(); }); } diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java index a8fb979e1..c50c10eb4 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java @@ -5,15 +5,29 @@ package io.modelcontextprotocol.client; import java.time.Duration; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; + +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult; import io.modelcontextprotocol.util.Assert; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * A synchronous client implementation for the Model Context Protocol (MCP) that wraps an @@ -62,14 +76,28 @@ public class McpSyncClient implements AutoCloseable { private final McpAsyncClient delegate; + /** JSON object mapper for message serialization/deserialization */ + protected ObjectMapper objectMapper; + /** * Create a new McpSyncClient with the given delegate. * @param delegate the asynchronous kernel on top of which this synchronous client * provides a blocking API. */ McpSyncClient(McpAsyncClient delegate) { + this(delegate, new ObjectMapper()); + } + + /** + * Create a new McpSyncClient with the given delegate. + * @param delegate the asynchronous kernel on top of which this synchronous client + * provides a blocking API. + * @param objectMapper the object mapper for JSON serialization/deserialization + */ + McpSyncClient(McpAsyncClient delegate, ObjectMapper objectMapper) { Assert.notNull(delegate, "The delegate can not be null"); this.delegate = delegate; + this.objectMapper = objectMapper; } /** @@ -206,7 +234,8 @@ public Object ping() { /** * Calls a tool provided by the server. Tools enable servers to expose executable * functionality that can interact with external systems, perform computations, and - * take actions in the real world. + * take actions in the real world. If tool contains an output schema, validates the + * tool result structured content against the output schema. * @param callToolRequest The request containing: - name: The name of the tool to call * (must match a tool name from tools/list) - arguments: Arguments that conform to the * tool's input schema @@ -215,7 +244,54 @@ public Object ping() { * Boolean indicating if the execution failed (true) or succeeded (false/absent) */ public McpSchema.CallToolResult callTool(McpSchema.CallToolRequest callToolRequest) { - return this.delegate.callTool(callToolRequest).block(); + McpSchema.CallToolResult result = this.delegate.callTool(callToolRequest).block(); + ConcurrentHashMap> toolsOutputSchemaCache = this.delegate + .getToolsOutputSchemaCache(); + // Should not be triggered but added for completeness + if (!toolsOutputSchemaCache.containsKey(callToolRequest.name())) { + throw new McpError("Tool with name '" + callToolRequest.name() + "' not found"); + } + Optional optOutputSchema = toolsOutputSchemaCache.get(callToolRequest.name()); + if (result != null && optOutputSchema != null && optOutputSchema.isPresent()) { + if (result.structuredContent() == null) { + throw new McpError("CallToolResult validation failed: structuredContent is null and " + + "does not match tool outputSchema."); + } + McpSchema.JsonSchema outputSchema = optOutputSchema.get(); + + try { + // Convert outputSchema to string + String outputSchemaString = this.objectMapper.writeValueAsString(outputSchema); + + // Create JsonSchema validator + ObjectNode schemaNode = (ObjectNode) this.objectMapper.readTree(outputSchemaString); + // Set additional properties to false if not specified in output schema + if (!schemaNode.has("additionalProperties")) { + schemaNode.put("additionalProperties", false); + } + JsonSchema schema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(schemaNode); + + // Convert structured content in reult to JsonNode + JsonNode jsonNode = this.objectMapper.valueToTree(result.structuredContent()); + + // Validate outputSchema against structuredContent + Set validationResult = schema.validate(jsonNode); + + // Check if validation passed + if (!validationResult.isEmpty()) { + // Handle validation errors + throw new McpError( + "CallToolResult validation failed: structuredContent does not match tool outputSchema."); + } + } + catch (JsonProcessingException e) { + // Log warning if output schema can't be parsed to prevent erroring out + // for successful call tool request + logger.warn("Failed to validate CallToolResult: Error parsing tool outputSchema: {}", e); + } + } + return result; } /** diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index e21d53c80..05c7bfe35 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -10,6 +10,9 @@ import java.util.List; import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -18,9 +21,8 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo.As; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; + import io.modelcontextprotocol.util.Assert; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Based on the JSON-RPC 2.0 @@ -750,16 +752,25 @@ public record JsonSchema( // @formatter:off * @param inputSchema A JSON Schema object that describes the expected structure of * the arguments when calling this tool. This allows clients to validate tool * arguments before sending them to the server. + * @param outputSchema An optional JSON Schema object that describes the expected + * output structure of this tool. If set, the clients must validate that the results + * from that tool contain a `structuredContent` field whose contents validate against + * the declared `outputSchema`. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record Tool( // @formatter:off @JsonProperty("name") String name, @JsonProperty("description") String description, - @JsonProperty("inputSchema") JsonSchema inputSchema) { + @JsonProperty("inputSchema") JsonSchema inputSchema, + @JsonProperty("outputSchema") JsonSchema outputSchema) { - public Tool(String name, String description, String schema) { - this(name, description, parseSchema(schema)); + public Tool(String name, String description, String inputSchema) { + this(name, description, parseSchema(inputSchema), null); + } + + public Tool(String name, String description, String inputSchema, String outputSchema) { + this(name, description, parseSchema(inputSchema), parseSchema(outputSchema)); } } // @formatter:on @@ -805,15 +816,19 @@ private static Map parseJsonArguments(String jsonArguments) { * The server's response to a tools/call request from the client. * * @param content A list of content items representing the tool's output. Each item can be text, an image, - * or an embedded resource. + * or an embedded resource. * @param isError If true, indicates that the tool execution failed and the content contains error information. - * If false or absent, indicates successful execution. + * If false or absent, indicates successful execution. + * @param structuredContent An optional map of structured content. If present, the client must validate that + * the results from that tool contain a `structuredContent` field whose contents validate against the declared + * `outputSchema`. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record CallToolResult( // @formatter:off @JsonProperty("content") List content, - @JsonProperty("isError") Boolean isError) { + @JsonProperty("isError") Boolean isError, + @JsonProperty("structuredContent") Map structuredContent) { /** * Creates a new instance of {@link CallToolResult} with a string containing the @@ -825,7 +840,20 @@ public record CallToolResult( // @formatter:off * If false or absent, indicates successful execution. */ public CallToolResult(String content, Boolean isError) { - this(List.of(new TextContent(content)), isError); + this(List.of(new TextContent(content)), isError, null); + } + + /** + * Creates a new instance of {@link CallToolResult} with a string containing the + * tool result. + * + * @param content The content of the tool result. This will be mapped to a one-sized list + * with a {@link TextContent} element. + * @param isError If true, indicates that the tool execution failed and the content contains error information. + * If false or absent, indicates successful execution. + */ + public CallToolResult(List content, Boolean isError) { + this(content, isError, null); } /** @@ -842,6 +870,7 @@ public static Builder builder() { public static class Builder { private List content = new ArrayList<>(); private Boolean isError; + private Map structuredContent; /** * Sets the content list for the tool result. @@ -867,6 +896,17 @@ public Builder textContent(List textContent) { return this; } + /** + * Sets the structured content for the tool result. + * @param structuredContent the structured content + * @return this builder + */ + public Builder structuredContent(Map structuredContent) { + Assert.notNull(structuredContent, "structuredContent must not be null"); + this.structuredContent = structuredContent; + return this; + } + /** * Adds a content item to the tool result. * @param contentItem the content item to add @@ -907,7 +947,7 @@ public Builder isError(Boolean isError) { * @return a new CallToolResult instance */ public CallToolResult build() { - return new CallToolResult(content, isError); + return new CallToolResult(content, isError, structuredContent); } } diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java b/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java index 0b38da857..231786362 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java @@ -4,8 +4,16 @@ package io.modelcontextprotocol.server; +import java.util.HashMap; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; @@ -17,13 +25,6 @@ import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; import io.modelcontextprotocol.spec.McpSchema.Tool; import io.modelcontextprotocol.spec.McpServerTransportProvider; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Test suite for the {@link McpSyncServer} that can be used with different @@ -124,7 +125,7 @@ void testAddTool() { @Test void testAddDuplicateTool() { - Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); + Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema, emptyJsonSchema); var mcpSyncServer = McpServer.sync(createMcpTransportProvider()) .serverInfo("test-server", "1.0.0") @@ -133,7 +134,7 @@ void testAddDuplicateTool() { .build(); assertThatThrownBy(() -> mcpSyncServer.addTool(new McpServerFeatures.SyncToolSpecification(duplicateTool, - (exchange, args) -> new CallToolResult(List.of(), false)))) + (exchange, args) -> new CallToolResult(List.of(), false, new HashMap())))) .isInstanceOf(McpError.class) .hasMessage("Tool with name '" + TEST_TOOL_NAME + "' already exists"); diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProviderIntegrationTests.java b/mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProviderIntegrationTests.java index dc9d1cfab..3d7e45648 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProviderIntegrationTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProviderIntegrationTests.java @@ -12,6 +12,19 @@ import java.util.function.Function; import java.util.stream.Collectors; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.LifecycleState; +import org.apache.catalina.startup.Tomcat; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.awaitility.Awaitility.await; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import static org.mockito.Mockito.mock; +import org.springframework.web.client.RestClient; + import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.client.McpClient; @@ -32,23 +45,9 @@ import io.modelcontextprotocol.spec.McpSchema.Root; import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; import io.modelcontextprotocol.spec.McpSchema.Tool; -import org.apache.catalina.LifecycleException; -import org.apache.catalina.LifecycleState; -import org.apache.catalina.startup.Tomcat; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; -import org.springframework.web.client.RestClient; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.awaitility.Awaitility.await; -import static org.mockito.Mockito.mock; - class HttpServletSseServerTransportProviderIntegrationTests { private static final int PORT = TomcatTestUtil.findAvailablePort(); @@ -827,6 +826,115 @@ void testToolListChangeHandlingSuccess() { mcpServer.close(); } + @Test + void testToolCallStructuredOutputSuccess() { + String outputSchema = """ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "count": { + "type": "integer" + } + } + } + """; + + CallToolResult callResponse = new McpSchema.CallToolResult(null, null, Map.of("message", "mks", "count", 1)); + + McpServerFeatures.AsyncToolSpecification toolWithOutputSchema = new McpServerFeatures.AsyncToolSpecification( + new McpSchema.Tool("toolWithOutputSchema", "tool1 description", emptyJsonSchema, outputSchema), + (exchange, request) -> { + return Mono.just(callResponse); + }); + + var mcpServer = McpServer.async(mcpServerTransportProvider) + .serverInfo("test-server", "1.0.0") + .tools(toolWithOutputSchema) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(mcpClient.listTools().tools()).contains(toolWithOutputSchema.tool()); + + CallToolResult response = mcpClient + .callTool(new McpSchema.CallToolRequest("toolWithOutputSchema", Map.of())); + + assertThat(response).isNotNull(); + assertThat(response).isEqualTo(callResponse); + } + mcpServer.close(); + } + + @Test + void testToolCallStructuredOutputValidationFailure() { + String outputSchema = """ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "cnt": { + "type": "integer" + } + } + } + """; + + CallToolResult callResponseWithStructuredContent = new McpSchema.CallToolResult(null, null, + Map.of("message", "hello", "count", 1)); + CallToolResult callResponseWithoutStructuredContent = new McpSchema.CallToolResult( + "{\"message\":\"hello\",\"count\":1}", null); + + McpServerFeatures.AsyncToolSpecification toolWithOutputSchema1 = new McpServerFeatures.AsyncToolSpecification( + new McpSchema.Tool("toolWithOutputSchema1", "toolWithOutputSchema1 description", emptyJsonSchema, + outputSchema), + (exchange, request) -> { + return Mono.just(callResponseWithStructuredContent); + }); + McpServerFeatures.AsyncToolSpecification toolWithOutputSchema2 = new McpServerFeatures.AsyncToolSpecification( + new McpSchema.Tool("toolWithOutputSchema2", "toolWithOutputSchema2 description", emptyJsonSchema, + outputSchema), + (exchange, request) -> { + return Mono.just(callResponseWithoutStructuredContent); + }); + + var mcpServer = McpServer.async(mcpServerTransportProvider) + .serverInfo("test-server", "1.0.0") + .tools(toolWithOutputSchema1, toolWithOutputSchema2) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(mcpClient.listTools().tools()).contains(toolWithOutputSchema1.tool()); + + assertThatExceptionOfType(McpError.class).isThrownBy(() -> { + mcpClient.callTool(new McpSchema.CallToolRequest("toolWithOutputSchema1", Map.of())); + }) + .withMessageContaining( + "CallToolResult validation failed: structuredContent does not match tool outputSchema."); + + assertThatExceptionOfType(McpError.class).isThrownBy(() -> { + mcpClient.callTool(new McpSchema.CallToolRequest("toolWithOutputSchema2", Map.of())); + }) + .withMessageContaining( + "CallToolResult validation failed: structuredContent is null and does not match tool outputSchema."); + + } + mcpServer.close(); + } + @Test void testInitialize() { var mcpServer = McpServer.sync(mcpServerTransportProvider).build();