From 70111429a4c4c205b8af0b32dddaa854e367359a Mon Sep 17 00:00:00 2001 From: Anurag Pant Date: Mon, 9 Jun 2025 01:24:32 +0000 Subject: [PATCH 1/3] feat: Add Tool.outputSchema and CallToolResult.structuredContent - Implement support for outputSchema in Tool and structuredContent in CallToolResult. - Add a client-side cache that maintains track of all server tools and their output schemas (if any). - Implement a mechanism to refresh the client side cache when encountering a tool call request for an uncached tool. - Implement json validation between tool's output schema and result's structured content, when an output schema is specified for the tool. --- .../.LCKAbstractMcpSyncServerTests.java~ | 1 + .../server/AbstractMcpSyncServerTests.java | 6 +- mcp/pom.xml | 7 + .../client/McpAsyncClient.java | 56 +++++++- .../client/McpSyncClient.java | 74 +++++++++- .../modelcontextprotocol/spec/McpSchema.java | 61 ++++++-- .../server/AbstractMcpSyncServerTests.java | 19 +-- ...rverTransportProviderIntegrationTests.java | 136 ++++++++++++++++-- 8 files changed, 318 insertions(+), 42 deletions(-) create mode 100644 mcp-test/src/main/java/io/modelcontextprotocol/server/.LCKAbstractMcpSyncServerTests.java~ diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/server/.LCKAbstractMcpSyncServerTests.java~ b/mcp-test/src/main/java/io/modelcontextprotocol/server/.LCKAbstractMcpSyncServerTests.java~ new file mode 100644 index 000000000..5c75cd9fa --- /dev/null +++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/.LCKAbstractMcpSyncServerTests.java~ @@ -0,0 +1 @@ +/local/home/pantanur/workspace/java-sdk/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java \ No newline at end of file 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 e3a997ba3..6b905eeb0 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -14,6 +14,7 @@ import java.util.function.Function; import com.fasterxml.jackson.core.type.TypeReference; + import io.modelcontextprotocol.spec.McpClientSession; import io.modelcontextprotocol.spec.McpClientSession.NotificationHandler; import io.modelcontextprotocol.spec.McpClientSession.RequestHandler; @@ -33,8 +34,11 @@ import io.modelcontextprotocol.spec.McpTransport; 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; @@ -123,6 +127,11 @@ public class McpAsyncClient { */ private McpSchema.Implementation serverInfo; + /** + * Cached tool output schemas. + */ + private final HashMap 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. @@ -171,6 +180,7 @@ public class McpAsyncClient { this.transport = transport; this.roots = new ConcurrentHashMap<>(features.roots()); this.initializationTimeout = initializationTimeout; + this.toolsOutputSchemaCache = new HashMap<>(); // Request Handlers Map> requestHandlers = new HashMap<>(); @@ -287,6 +297,14 @@ public McpSchema.Implementation getClientInfo() { return this.clientInfo; } + /** + * Get the cached tool output schemas. + * @return The cached tool output schemas + */ + public HashMap getToolsOutputSchemaCache() { + return this.toolsOutputSchemaCache; + } + /** * Closes the client connection immediately. */ @@ -525,7 +543,13 @@ public Mono callTool(McpSchema.CallToolRequest callToo if (this.serverCapabilities.tools() == null) { return Mono.error(new McpError("Server does not provide tools capability")); } - return this.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(this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, + CALL_TOOL_RESULT_TYPE_REF)); }); } @@ -547,8 +571,34 @@ public Mono listTools(String cursor) { if (this.serverCapabilities.tools() == null) { return Mono.error(new McpError("Server does not provide tools capability")); } - return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor), - LIST_TOOLS_RESULT_TYPE_REF); + return this.mcpSession + .sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor), + 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(), 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.withInitializationCheck("refreshing tool output schema cache", initializedResult -> { + + // 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..e6eea0f63 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java @@ -5,15 +5,28 @@ package io.modelcontextprotocol.client; import java.time.Duration; +import java.util.HashMap; +import java.util.Set; +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 @@ -55,6 +68,8 @@ public class McpSyncClient implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(McpSyncClient.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + // TODO: Consider providing a client config to set this properly // this is currently a concern only because AutoCloseable is used - perhaps it // is not a requirement? @@ -206,7 +221,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 +231,53 @@ 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(); + HashMap 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"); + } + if (result != null && toolsOutputSchemaCache.get(callToolRequest.name()) != null) { + if (result.structuredContent() == null) { + throw new McpError("CallToolResult validation failed: structuredContent is null and " + + "does not match tool outputSchema."); + } + + McpSchema.JsonSchema outputSchema = toolsOutputSchemaCache.get(callToolRequest.name()); + + try { + // Convert outputSchema to string + String outputSchemaString = OBJECT_MAPPER.writeValueAsString(outputSchema); + + // Create JsonSchema validator + ObjectNode schemaNode = (ObjectNode) OBJECT_MAPPER.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 = OBJECT_MAPPER.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 error if output schema can't be parsed to prevent erroring out for + // successful call tool request + logger.error("Encountered exception when parsing outputSchema: {}", e); + } + } + return result; } /** @@ -353,4 +415,8 @@ public McpSchema.CompleteResult completeCompletion(McpSchema.CompleteRequest com return this.delegate.completeCompletion(completeRequest).block(); } + private void isStrict(boolean b) { + throw new UnsupportedOperationException("Not supported yet."); + } + } diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 8df8a1584..f54c949a8 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -9,6 +9,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; @@ -18,9 +22,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 @@ -720,16 +723,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 inputSchema) { + this(name, description, parseSchema(inputSchema), null); + } - public Tool(String name, String description, String schema) { - this(name, description, parseSchema(schema)); + public Tool(String name, String description, String inputSchema, String outputSchema) { + this(name, description, parseSchema(inputSchema), parseSchema(outputSchema)); } } // @formatter:on @@ -775,15 +787,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 @@ -795,7 +811,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); } /** @@ -812,6 +841,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. @@ -837,6 +867,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 @@ -877,7 +918,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 2ff6325a4..e67f225b3 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; @@ -30,23 +43,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(); @@ -614,6 +613,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(); From 31f453eee5ca35f6779f78aa16fcb31bd6d916b6 Mon Sep 17 00:00:00 2001 From: Anurag Pant Date: Mon, 9 Jun 2025 21:18:16 +0000 Subject: [PATCH 2/3] Minor fixes/changes --- .../.LCKAbstractMcpSyncServerTests.java~ | 1 - .../client/McpAsyncClient.java | 4 +-- .../client/McpSyncClient.java | 26 ++++++++++++------- 3 files changed, 19 insertions(+), 12 deletions(-) delete mode 100644 mcp-test/src/main/java/io/modelcontextprotocol/server/.LCKAbstractMcpSyncServerTests.java~ diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/server/.LCKAbstractMcpSyncServerTests.java~ b/mcp-test/src/main/java/io/modelcontextprotocol/server/.LCKAbstractMcpSyncServerTests.java~ deleted file mode 100644 index 5c75cd9fa..000000000 --- a/mcp-test/src/main/java/io/modelcontextprotocol/server/.LCKAbstractMcpSyncServerTests.java~ +++ /dev/null @@ -1 +0,0 @@ -/local/home/pantanur/workspace/java-sdk/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java \ No newline at end of file diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index 6b905eeb0..f46694b99 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -130,7 +130,7 @@ public class McpAsyncClient { /** * Cached tool output schemas. */ - private final HashMap toolsOutputSchemaCache; + private final ConcurrentHashMap toolsOutputSchemaCache; /** * Roots define the boundaries of where servers can operate within the filesystem, @@ -180,7 +180,7 @@ public class McpAsyncClient { this.transport = transport; this.roots = new ConcurrentHashMap<>(features.roots()); this.initializationTimeout = initializationTimeout; - this.toolsOutputSchemaCache = new HashMap<>(); + this.toolsOutputSchemaCache = new ConcurrentHashMap<>(); // Request Handlers Map> requestHandlers = new HashMap<>(); diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java index e6eea0f63..b279adc52 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java @@ -68,8 +68,6 @@ public class McpSyncClient implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(McpSyncClient.class); - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - // TODO: Consider providing a client config to set this properly // this is currently a concern only because AutoCloseable is used - perhaps it // is not a requirement? @@ -77,14 +75,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; } /** @@ -247,10 +259,10 @@ public McpSchema.CallToolResult callTool(McpSchema.CallToolRequest callToolReque try { // Convert outputSchema to string - String outputSchemaString = OBJECT_MAPPER.writeValueAsString(outputSchema); + String outputSchemaString = this.objectMapper.writeValueAsString(outputSchema); // Create JsonSchema validator - ObjectNode schemaNode = (ObjectNode) OBJECT_MAPPER.readTree(outputSchemaString); + 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); @@ -259,7 +271,7 @@ public McpSchema.CallToolResult callTool(McpSchema.CallToolRequest callToolReque .getSchema(schemaNode); // Convert structured content in reult to JsonNode - JsonNode jsonNode = OBJECT_MAPPER.valueToTree(result.structuredContent()); + JsonNode jsonNode = this.objectMapper.valueToTree(result.structuredContent()); // Validate outputSchema against structuredContent Set validationResult = schema.validate(jsonNode); @@ -415,8 +427,4 @@ public McpSchema.CompleteResult completeCompletion(McpSchema.CompleteRequest com return this.delegate.completeCompletion(completeRequest).block(); } - private void isStrict(boolean b) { - throw new UnsupportedOperationException("Not supported yet."); - } - } From aaf7add38cd3d0e1e27d88de7e725537ffda30aa Mon Sep 17 00:00:00 2001 From: Anurag Pant Date: Mon, 9 Jun 2025 23:25:49 +0000 Subject: [PATCH 3/3] Store optional json schemas in cache --- .../client/McpAsyncClient.java | 9 ++++++--- .../client/McpSyncClient.java | 18 ++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index f46694b99..0a9f3c181 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -8,6 +8,7 @@ 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.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; @@ -26,6 +27,7 @@ import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult; 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; @@ -130,7 +132,7 @@ public class McpAsyncClient { /** * Cached tool output schemas. */ - private final ConcurrentHashMap toolsOutputSchemaCache; + private final ConcurrentHashMap> toolsOutputSchemaCache; /** * Roots define the boundaries of where servers can operate within the filesystem, @@ -301,7 +303,7 @@ public McpSchema.Implementation getClientInfo() { * Get the cached tool output schemas. * @return The cached tool output schemas */ - public HashMap getToolsOutputSchemaCache() { + public ConcurrentHashMap> getToolsOutputSchemaCache() { return this.toolsOutputSchemaCache; } @@ -579,7 +581,8 @@ public Mono listTools(String cursor) { if (result.tools() != null) { // Cache tools output schema result.tools() - .forEach(tool -> this.toolsOutputSchemaCache.put(tool.name(), tool.outputSchema())); + .forEach(tool -> this.toolsOutputSchemaCache.put(tool.name(), + Optional.ofNullable(tool.outputSchema()))); } }); }); diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java index b279adc52..c50c10eb4 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java @@ -5,8 +5,9 @@ package io.modelcontextprotocol.client; import java.time.Duration; -import java.util.HashMap; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -244,18 +245,19 @@ public Object ping() { */ public McpSchema.CallToolResult callTool(McpSchema.CallToolRequest callToolRequest) { McpSchema.CallToolResult result = this.delegate.callTool(callToolRequest).block(); - HashMap toolsOutputSchemaCache = this.delegate.getToolsOutputSchemaCache(); + 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"); } - if (result != null && toolsOutputSchemaCache.get(callToolRequest.name()) != null) { + 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 = toolsOutputSchemaCache.get(callToolRequest.name()); + McpSchema.JsonSchema outputSchema = optOutputSchema.get(); try { // Convert outputSchema to string @@ -284,9 +286,9 @@ public McpSchema.CallToolResult callTool(McpSchema.CallToolRequest callToolReque } } catch (JsonProcessingException e) { - // Log error if output schema can't be parsed to prevent erroring out for - // successful call tool request - logger.error("Encountered exception when parsing outputSchema: {}", 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;