diff --git a/mcp-spring/mcp-spring-webflux/pom.xml b/mcp-spring/mcp-spring-webflux/pom.xml
index 26452fe95..fdec82377 100644
--- a/mcp-spring/mcp-spring-webflux/pom.xml
+++ b/mcp-spring/mcp-spring-webflux/pom.xml
@@ -127,6 +127,13 @@
test
+
+ net.javacrumbs.json-unit
+ json-unit-assertj
+ ${json-unit-assertj.version}
+ test
+
+
diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java
index cac0ffac9..c711a2853 100644
--- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java
+++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java
@@ -47,6 +47,10 @@
import static org.assertj.core.api.Assertions.assertWith;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.mock;
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
+
+import net.javacrumbs.jsonunit.core.Option;
class WebFluxSseIntegrationTests {
@@ -1023,4 +1027,250 @@ void testCompletionShouldReturnExpectedSuggestions(String clientType) {
mcpServer.close();
}
-}
\ No newline at end of file
+ // ---------------------------------------
+ // Tool Structured Output Schema Tests
+ // ---------------------------------------
+
+ @ParameterizedTest(name = "{0} : {displayName} ")
+ @ValueSource(strings = { "httpclient", "webflux" })
+ void testStructuredOutputValidationSuccess(String clientType) {
+ var clientBuilder = clientBuilders.get(clientType);
+
+ // Create a tool with output schema
+ Map outputSchema = Map.of(
+ "type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
+ Map.of("type", "string"), "timestamp", Map.of("type", "string")),
+ "required", List.of("result", "operation"));
+
+ Tool calculatorTool = new Tool("calculator", "Performs mathematical calculations", (McpSchema.JsonSchema) null,
+ outputSchema, (McpSchema.ToolAnnotations) null);
+
+ McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
+ (exchange, request) -> {
+ String expression = (String) request.getOrDefault("expression", "2 + 3");
+ double result = evaluateExpression(expression);
+ return CallToolResult.builder()
+ .structuredContent(
+ Map.of("result", result, "operation", expression, "timestamp", "2024-01-01T10:00:00Z"))
+ .build();
+ });
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tools(tool)
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Verify tool is listed with output schema
+ var toolsList = mcpClient.listTools();
+ assertThat(toolsList.tools()).hasSize(1);
+ assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator");
+ // Note: outputSchema might be null in sync server, but validation still works
+
+ // Call tool with valid structured output
+ CallToolResult response = mcpClient
+ .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
+
+ assertThat(response).isNotNull();
+ assertThat(response.isError()).isFalse();
+ assertThat(response.content()).hasSize(1);
+ assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
+
+ assertThatJson(((McpSchema.TextContent) response.content().get(0)).text()).when(Option.IGNORING_ARRAY_ORDER)
+ .when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
+ .isObject()
+ .isEqualTo(json("""
+ {"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}"""));
+
+ assertThat(response.structuredContent()).isNotNull();
+ assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
+ .when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
+ .isObject()
+ .isEqualTo(json("""
+ {"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}"""));
+ }
+
+ mcpServer.close();
+ }
+
+ @ParameterizedTest(name = "{0} : {displayName} ")
+ @ValueSource(strings = { "httpclient", "webflux" })
+ void testStructuredOutputValidationFailure(String clientType) {
+ var clientBuilder = clientBuilders.get(clientType);
+
+ // Create a tool with output schema
+ Map outputSchema = Map.of("type", "object", "properties",
+ Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required",
+ List.of("result", "operation"));
+
+ Tool calculatorTool = new Tool("calculator", "Performs mathematical calculations", (McpSchema.JsonSchema) null,
+ outputSchema, (McpSchema.ToolAnnotations) null);
+
+ McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
+ (exchange, request) -> {
+ // Return invalid structured output. Result should be number, missing
+ // operation
+ return CallToolResult.builder()
+ .addTextContent("Invalid calculation")
+ .structuredContent(Map.of("result", "not-a-number", "extra", "field"))
+ .build();
+ });
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tools(tool)
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Call tool with invalid structured output
+ CallToolResult response = mcpClient
+ .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
+
+ assertThat(response).isNotNull();
+ assertThat(response.isError()).isTrue();
+ assertThat(response.content()).hasSize(1);
+ assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
+
+ String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
+ assertThat(errorMessage).contains("Validation failed");
+ }
+
+ mcpServer.close();
+ }
+
+ @ParameterizedTest(name = "{0} : {displayName} ")
+ @ValueSource(strings = { "httpclient", "webflux" })
+ void testStructuredOutputMissingStructuredContent(String clientType) {
+ var clientBuilder = clientBuilders.get(clientType);
+
+ // Create a tool with output schema
+ Map outputSchema = Map.of("type", "object", "properties",
+ Map.of("result", Map.of("type", "number")), "required", List.of("result"));
+
+ Tool calculatorTool = new Tool("calculator", "Performs mathematical calculations", (McpSchema.JsonSchema) null,
+ outputSchema, (McpSchema.ToolAnnotations) null);
+
+ McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
+ (exchange, request) -> {
+ // Return result without structured content but tool has output schema
+ return CallToolResult.builder().addTextContent("Calculation completed").build();
+ });
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tools(tool)
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Call tool that should return structured content but doesn't
+ CallToolResult response = mcpClient
+ .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
+
+ assertThat(response).isNotNull();
+ assertThat(response.isError()).isTrue();
+ assertThat(response.content()).hasSize(1);
+ assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
+
+ String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
+ assertThat(errorMessage).isEqualTo(
+ "Response missing structured content which is expected when calling tool with non-empty outputSchema");
+ }
+
+ mcpServer.close();
+ }
+
+ @ParameterizedTest(name = "{0} : {displayName} ")
+ @ValueSource(strings = { "httpclient", "webflux" })
+ void testStructuredOutputRuntimeToolAddition(String clientType) {
+ var clientBuilder = clientBuilders.get(clientType);
+
+ // Start server without tools
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Initially no tools
+ assertThat(mcpClient.listTools().tools()).isEmpty();
+
+ // Add tool with output schema at runtime
+ Map outputSchema = Map.of("type", "object", "properties",
+ Map.of("message", Map.of("type", "string"), "count", Map.of("type", "integer")), "required",
+ List.of("message", "count"));
+
+ Tool dynamicTool = new Tool("dynamic-tool", "Dynamically added tool", (McpSchema.JsonSchema) null,
+ outputSchema, (McpSchema.ToolAnnotations) null);
+
+ McpServerFeatures.SyncToolSpecification toolSpec = new McpServerFeatures.SyncToolSpecification(dynamicTool,
+ (exchange, request) -> {
+ int count = (Integer) request.getOrDefault("count", 1);
+ return CallToolResult.builder()
+ .addTextContent("Dynamic tool executed " + count + " times")
+ .structuredContent(Map.of("message", "Dynamic execution", "count", count))
+ .build();
+ });
+
+ // Add tool to server
+ mcpServer.addTool(toolSpec);
+
+ // Wait for tool list change notification
+ await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
+ assertThat(mcpClient.listTools().tools()).hasSize(1);
+ });
+
+ // Verify tool was added with output schema
+ var toolsList = mcpClient.listTools();
+ assertThat(toolsList.tools()).hasSize(1);
+ assertThat(toolsList.tools().get(0).name()).isEqualTo("dynamic-tool");
+ // Note: outputSchema might be null in sync server, but validation still works
+
+ // Call dynamically added tool
+ CallToolResult response = mcpClient
+ .callTool(new McpSchema.CallToolRequest("dynamic-tool", Map.of("count", 3)));
+
+ assertThat(response).isNotNull();
+ assertThat(response.isError()).isFalse();
+ assertThat(response.content()).hasSize(1);
+ assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
+ assertThat(((McpSchema.TextContent) response.content().get(0)).text())
+ .isEqualTo("Dynamic tool executed 3 times");
+
+ assertThat(response.structuredContent()).isNotNull();
+ assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
+ .when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
+ .isObject()
+ .isEqualTo(json("""
+ {"count":3,"message":"Dynamic execution"}"""));
+ }
+
+ mcpServer.close();
+ }
+
+ private double evaluateExpression(String expression) {
+ // Simple expression evaluator for testing
+ return switch (expression) {
+ case "2 + 3" -> 5.0;
+ case "10 * 2" -> 20.0;
+ case "7 + 8" -> 15.0;
+ case "5 + 3" -> 8.0;
+ default -> 0.0;
+ };
+ }
+
+}
diff --git a/mcp-spring/mcp-spring-webmvc/pom.xml b/mcp-spring/mcp-spring-webmvc/pom.xml
index 48d1c3465..4c6d37bf9 100644
--- a/mcp-spring/mcp-spring-webmvc/pom.xml
+++ b/mcp-spring/mcp-spring-webmvc/pom.xml
@@ -128,7 +128,14 @@
test
+
+ net.javacrumbs.json-unit
+ json-unit-assertj
+ ${json-unit-assertj.version}
+ test
+
+
-
\ No newline at end of file
+
diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationTests.java
index 3f3f7be62..ffb9f33e2 100644
--- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationTests.java
+++ b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationTests.java
@@ -45,6 +45,10 @@
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.mock;
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
+
+import net.javacrumbs.jsonunit.core.Option;
class WebMvcSseIntegrationTests {
@@ -862,4 +866,242 @@ void testInitialize() {
mcpServer.close();
}
+ // ---------------------------------------
+ // Tool Structured Output Schema Tests
+ // ---------------------------------------
+
+ @Test
+ void testStructuredOutputValidationSuccess() {
+ // Create a tool with output schema
+ Map outputSchema = Map.of(
+ "type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
+ Map.of("type", "string"), "timestamp", Map.of("type", "string")),
+ "required", List.of("result", "operation"));
+
+ Tool calculatorTool = new Tool("calculator", "Performs mathematical calculations", (McpSchema.JsonSchema) null,
+ outputSchema, (McpSchema.ToolAnnotations) null);
+
+ McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
+ (exchange, request) -> {
+ String expression = (String) request.getOrDefault("expression", "2 + 3");
+ double result = evaluateExpression(expression);
+ return CallToolResult.builder()
+ .structuredContent(
+ Map.of("result", result, "operation", expression, "timestamp", "2024-01-01T10:00:00Z"))
+ .build();
+ });
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tools(tool)
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Verify tool is listed with output schema
+ var toolsList = mcpClient.listTools();
+ assertThat(toolsList.tools()).hasSize(1);
+ assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator");
+ // Note: outputSchema might be null in sync server, but validation still works
+
+ // Call tool with valid structured output
+ CallToolResult response = mcpClient
+ .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
+
+ assertThat(response).isNotNull();
+ assertThat(response.isError()).isFalse();
+
+ // In WebMVC, structured content is returned properly
+ if (response.structuredContent() != null) {
+ assertThat(response.structuredContent()).containsEntry("result", 5.0)
+ .containsEntry("operation", "2 + 3")
+ .containsEntry("timestamp", "2024-01-01T10:00:00Z");
+ }
+ else {
+ // Fallback to checking content if structured content is not available
+ assertThat(response.content()).isNotEmpty();
+ }
+
+ assertThat(response.structuredContent()).isNotNull();
+ assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
+ .when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
+ .isObject()
+ .isEqualTo(json("""
+ {"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}"""));
+ }
+
+ mcpServer.close();
+ }
+
+ @Test
+ void testStructuredOutputValidationFailure() {
+ // Create a tool with output schema
+ Map outputSchema = Map.of("type", "object", "properties",
+ Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required",
+ List.of("result", "operation"));
+
+ Tool calculatorTool = new Tool("calculator", "Performs mathematical calculations", (McpSchema.JsonSchema) null,
+ outputSchema, (McpSchema.ToolAnnotations) null);
+
+ McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
+ (exchange, request) -> {
+ // Return invalid structured output. Result should be number, missing
+ // operation
+ return CallToolResult.builder()
+ .addTextContent("Invalid calculation")
+ .structuredContent(Map.of("result", "not-a-number", "extra", "field"))
+ .build();
+ });
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tools(tool)
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Call tool with invalid structured output
+ CallToolResult response = mcpClient
+ .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
+
+ assertThat(response).isNotNull();
+ assertThat(response.isError()).isTrue();
+ assertThat(response.content()).hasSize(1);
+ assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
+
+ String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
+ assertThat(errorMessage).contains("Validation failed");
+ }
+
+ mcpServer.close();
+ }
+
+ @Test
+ void testStructuredOutputMissingStructuredContent() {
+ // Create a tool with output schema
+ Map outputSchema = Map.of("type", "object", "properties",
+ Map.of("result", Map.of("type", "number")), "required", List.of("result"));
+
+ Tool calculatorTool = new Tool("calculator", "Performs mathematical calculations", (McpSchema.JsonSchema) null,
+ outputSchema, (McpSchema.ToolAnnotations) null);
+
+ McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
+ (exchange, request) -> {
+ // Return result without structured content but tool has output schema
+ return CallToolResult.builder().addTextContent("Calculation completed").build();
+ });
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tools(tool)
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Call tool that should return structured content but doesn't
+ CallToolResult response = mcpClient
+ .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
+
+ assertThat(response).isNotNull();
+ assertThat(response.isError()).isTrue();
+ assertThat(response.content()).hasSize(1);
+ assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
+
+ String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
+ assertThat(errorMessage).isEqualTo(
+ "Response missing structured content which is expected when calling tool with non-empty outputSchema");
+ }
+
+ mcpServer.close();
+ }
+
+ @Test
+ void testStructuredOutputRuntimeToolAddition() {
+ // Start server without tools
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Initially no tools
+ assertThat(mcpClient.listTools().tools()).isEmpty();
+
+ // Add tool with output schema at runtime
+ Map outputSchema = Map.of("type", "object", "properties",
+ Map.of("message", Map.of("type", "string"), "count", Map.of("type", "integer")), "required",
+ List.of("message", "count"));
+
+ Tool dynamicTool = new Tool("dynamic-tool", "Dynamically added tool", (McpSchema.JsonSchema) null,
+ outputSchema, (McpSchema.ToolAnnotations) null);
+
+ McpServerFeatures.SyncToolSpecification toolSpec = new McpServerFeatures.SyncToolSpecification(dynamicTool,
+ (exchange, request) -> {
+ int count = (Integer) request.getOrDefault("count", 1);
+ return CallToolResult.builder()
+ .addTextContent("Dynamic tool executed " + count + " times")
+ .structuredContent(Map.of("message", "Dynamic execution", "count", count))
+ .build();
+ });
+
+ // Add tool to server
+ mcpServer.addTool(toolSpec);
+
+ // Wait for tool list change notification
+ await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
+ assertThat(mcpClient.listTools().tools()).hasSize(1);
+ });
+
+ // Verify tool was added with output schema
+ var toolsList = mcpClient.listTools();
+ assertThat(toolsList.tools()).hasSize(1);
+ assertThat(toolsList.tools().get(0).name()).isEqualTo("dynamic-tool");
+ // Note: outputSchema might be null in sync server, but validation still works
+
+ // Call dynamically added tool
+ CallToolResult response = mcpClient
+ .callTool(new McpSchema.CallToolRequest("dynamic-tool", Map.of("count", 3)));
+
+ assertThat(response).isNotNull();
+ assertThat(response.isError()).isFalse();
+
+ assertThat(response.content()).hasSize(1);
+ assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
+ assertThat(((McpSchema.TextContent) response.content().get(0)).text())
+ .isEqualTo("Dynamic tool executed 3 times");
+
+ assertThat(response.structuredContent()).isNotNull();
+ assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
+ .when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
+ .isObject()
+ .isEqualTo(json("""
+ {"count":3,"message":"Dynamic execution"}"""));
+ }
+
+ mcpServer.close();
+ }
+
+ private double evaluateExpression(String expression) {
+ // Simple expression evaluator for testing
+ return switch (expression) {
+ case "2 + 3" -> 5.0;
+ case "10 * 2" -> 20.0;
+ case "7 + 8" -> 15.0;
+ case "5 + 3" -> 8.0;
+ default -> 0.0;
+ };
+ }
+
}
diff --git a/mcp/pom.xml b/mcp/pom.xml
index 773432827..f49cbdb9a 100644
--- a/mcp/pom.xml
+++ b/mcp/pom.xml
@@ -83,6 +83,22 @@
reactor-core
+
+ com.networknt
+ json-schema-validator
+ ${json-schema-validator.version}
+
+
+
+
+ jakarta.servlet
+ jakarta.servlet-api
+ ${jakarta.servlet.version}
+ provided
+
+
+
+
org.springframework
spring-webmvc
@@ -179,15 +195,6 @@
test
-
-
-
- jakarta.servlet
- jakarta.servlet-api
- ${jakarta.servlet.version}
- provided
-
-
org.apache.tomcat.embed
diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java
index 02ad955b9..3ea8c676e 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java
@@ -15,8 +15,13 @@
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.BiFunction;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
+
+import io.modelcontextprotocol.spec.JsonSchemaValidator;
import io.modelcontextprotocol.spec.McpClientSession;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
@@ -28,11 +33,10 @@
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpServerSession;
import io.modelcontextprotocol.spec.McpServerTransportProvider;
+import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.DeafaultMcpUriTemplateManagerFactory;
import io.modelcontextprotocol.util.McpUriTemplateManagerFactory;
import io.modelcontextprotocol.util.Utils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -86,6 +90,8 @@ public class McpAsyncServer {
private final ObjectMapper objectMapper;
+ private final JsonSchemaValidator jsonSchemaValidator;
+
private final McpSchema.ServerCapabilities serverCapabilities;
private final McpSchema.Implementation serverInfo;
@@ -119,18 +125,19 @@ public class McpAsyncServer {
*/
McpAsyncServer(McpServerTransportProvider mcpTransportProvider, ObjectMapper objectMapper,
McpServerFeatures.Async features, Duration requestTimeout,
- McpUriTemplateManagerFactory uriTemplateManagerFactory) {
+ McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator) {
this.mcpTransportProvider = mcpTransportProvider;
this.objectMapper = objectMapper;
this.serverInfo = features.serverInfo();
this.serverCapabilities = features.serverCapabilities();
this.instructions = features.instructions();
- this.tools.addAll(features.tools());
+ this.tools.addAll(withStructuredOutputHandling(jsonSchemaValidator, features.tools()));
this.resources.putAll(features.resources());
this.resourceTemplates.addAll(features.resourceTemplates());
this.prompts.putAll(features.prompts());
this.completions.putAll(features.completions());
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
+ this.jsonSchemaValidator = jsonSchemaValidator;
Map> requestHandlers = new HashMap<>();
@@ -286,15 +293,17 @@ public Mono addTool(McpServerFeatures.AsyncToolSpecification toolSpecifica
return Mono.error(new McpError("Server must be configured with tool capabilities"));
}
+ var wrappedToolSpecification = withStructuredOutputHandling(this.jsonSchemaValidator, toolSpecification);
+
return Mono.defer(() -> {
// Check for duplicate tool names
- if (this.tools.stream().anyMatch(th -> th.tool().name().equals(toolSpecification.tool().name()))) {
- return Mono
- .error(new McpError("Tool with name '" + toolSpecification.tool().name() + "' already exists"));
+ if (this.tools.stream().anyMatch(th -> th.tool().name().equals(wrappedToolSpecification.tool().name()))) {
+ return Mono.error(
+ new McpError("Tool with name '" + wrappedToolSpecification.tool().name() + "' already exists"));
}
- this.tools.add(toolSpecification);
- logger.debug("Added tool handler: {}", toolSpecification.tool().name());
+ this.tools.add(wrappedToolSpecification);
+ logger.debug("Added tool handler: {}", wrappedToolSpecification.tool().name());
if (this.serverCapabilities.tools().listChanged()) {
return notifyToolsListChanged();
@@ -303,6 +312,107 @@ public Mono addTool(McpServerFeatures.AsyncToolSpecification toolSpecifica
});
}
+ private static class StructuredOutputCallToolHandler
+ implements BiFunction, Mono> {
+
+ private final BiFunction, Mono> delegateCallToolResult;
+
+ private final JsonSchemaValidator jsonSchemaValidator;
+
+ private final Map outputSchema;
+
+ public StructuredOutputCallToolHandler(JsonSchemaValidator jsonSchemaValidator,
+ Map outputSchema,
+ BiFunction, Mono> delegateHandler) {
+
+ Assert.notNull(jsonSchemaValidator, "JsonSchemaValidator must not be null");
+ Assert.notNull(delegateHandler, "Delegate call tool result handler must not be null");
+
+ this.delegateCallToolResult = delegateHandler;
+ this.outputSchema = outputSchema;
+ this.jsonSchemaValidator = jsonSchemaValidator;
+ }
+
+ @Override
+ public Mono apply(McpAsyncServerExchange exchange, Map arguments) {
+
+ return this.delegateCallToolResult.apply(exchange, arguments).map(result -> {
+
+ if (outputSchema == null) {
+ if (result.structuredContent() != null) {
+ logger.warn(
+ "Tool call with no outputSchema is not expected to have a result with structured content, but got: {}",
+ result.structuredContent());
+ }
+ // Pass through. No validation is required if no output schema is
+ // provided.
+ return result;
+ }
+
+ // If an output schema is provided, servers MUST provide structured
+ // results that conform to this schema.
+ // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema
+ if (result.structuredContent() == null) {
+ logger.warn(
+ "Response missing structured content which is expected when calling tool with non-empty outputSchema");
+ return new CallToolResult(
+ "Response missing structured content which is expected when calling tool with non-empty outputSchema",
+ true);
+ }
+
+ // Validate the result against the output schema
+ var validation = this.jsonSchemaValidator.validate(outputSchema, result.structuredContent());
+
+ if (!validation.valid()) {
+ logger.warn("Tool call result validation failed: {}", validation.errorMessage());
+ return new CallToolResult(validation.errorMessage(), true);
+ }
+
+ if (Utils.isEmpty(result.content())) {
+ // For backwards compatibility, a tool that returns structured
+ // content SHOULD also return functionally equivalent unstructured
+ // content. (For example, serialized JSON can be returned in a
+ // TextContent block.)
+ // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content
+
+ return new CallToolResult(List.of(new McpSchema.TextContent(validation.jsonStructuredOutput())),
+ result.isError(), result.structuredContent());
+ }
+
+ return result;
+ });
+ }
+
+ }
+
+ private static List withStructuredOutputHandling(
+ JsonSchemaValidator jsonSchemaValidator, List tools) {
+
+ if (Utils.isEmpty(tools)) {
+ return tools;
+ }
+
+ return tools.stream().map(tool -> withStructuredOutputHandling(jsonSchemaValidator, tool)).toList();
+ }
+
+ private static McpServerFeatures.AsyncToolSpecification withStructuredOutputHandling(
+ JsonSchemaValidator jsonSchemaValidator, McpServerFeatures.AsyncToolSpecification toolSpecification) {
+
+ if (toolSpecification.call() instanceof StructuredOutputCallToolHandler) {
+ // If the tool is already wrapped, return it as is
+ return toolSpecification;
+ }
+
+ if (toolSpecification.tool().outputSchema() == null) {
+ // If the tool does not have an output schema, return it as is
+ return toolSpecification;
+ }
+
+ return new McpServerFeatures.AsyncToolSpecification(toolSpecification.tool(),
+ new StructuredOutputCallToolHandler(jsonSchemaValidator, toolSpecification.tool().outputSchema(),
+ toolSpecification.call()));
+ }
+
/**
* Remove a tool handler at runtime.
* @param toolName The name of the tool handler to remove
diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java
index d6ec2cc30..f7460d7fb 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java
@@ -14,6 +14,9 @@
import java.util.function.BiFunction;
import com.fasterxml.jackson.databind.ObjectMapper;
+
+import io.modelcontextprotocol.spec.DefaultJsonSchemaValidator;
+import io.modelcontextprotocol.spec.JsonSchemaValidator;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate;
@@ -166,6 +169,8 @@ class AsyncSpecification {
private McpSchema.ServerCapabilities serverCapabilities;
+ private JsonSchemaValidator jsonSchemaValidator;
+
private String instructions;
/**
@@ -624,6 +629,20 @@ public AsyncSpecification objectMapper(ObjectMapper objectMapper) {
return this;
}
+ /**
+ * Sets the JSON schema validator to use for validating tool and resource schemas.
+ * This ensures that the server's tools and resources conform to the expected
+ * schema definitions.
+ * @param jsonSchemaValidator The validator to use. Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if jsonSchemaValidator is null
+ */
+ public AsyncSpecification jsonSchemaValidator(JsonSchemaValidator jsonSchemaValidator) {
+ Assert.notNull(jsonSchemaValidator, "JsonSchemaValidator must not be null");
+ this.jsonSchemaValidator = jsonSchemaValidator;
+ return this;
+ }
+
/**
* Builds an asynchronous MCP server that provides non-blocking operations.
* @return A new instance of {@link McpAsyncServer} configured with this builder's
@@ -634,8 +653,10 @@ public McpAsyncServer build() {
this.resources, this.resourceTemplates, this.prompts, this.completions, this.rootsChangeHandlers,
this.instructions);
var mapper = this.objectMapper != null ? this.objectMapper : new ObjectMapper();
+ var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
+ : new DefaultJsonSchemaValidator(mapper);
return new McpAsyncServer(this.transportProvider, mapper, features, this.requestTimeout,
- this.uriTemplateManagerFactory);
+ this.uriTemplateManagerFactory, jsonSchemaValidator);
}
}
@@ -680,6 +701,8 @@ class SyncSpecification {
private final List resourceTemplates = new ArrayList<>();
+ private JsonSchemaValidator jsonSchemaValidator;
+
/**
* The Model Context Protocol (MCP) provides a standardized way for servers to
* expose prompt templates to clients. Prompts allow servers to provide structured
@@ -1116,6 +1139,12 @@ public SyncSpecification objectMapper(ObjectMapper objectMapper) {
return this;
}
+ public SyncSpecification jsonSchemaValidator(JsonSchemaValidator jsonSchemaValidator) {
+ Assert.notNull(jsonSchemaValidator, "JsonSchemaValidator must not be null");
+ this.jsonSchemaValidator = jsonSchemaValidator;
+ return this;
+ }
+
/**
* Builds a synchronous MCP server that provides blocking operations.
* @return A new instance of {@link McpSyncServer} configured with this builder's
@@ -1127,8 +1156,11 @@ public McpSyncServer build() {
this.rootsChangeHandlers, this.instructions);
McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures);
var mapper = this.objectMapper != null ? this.objectMapper : new ObjectMapper();
+ var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
+ : new DefaultJsonSchemaValidator(mapper);
+
var asyncServer = new McpAsyncServer(this.transportProvider, mapper, asyncFeatures, this.requestTimeout,
- this.uriTemplateManagerFactory);
+ this.uriTemplateManagerFactory, jsonSchemaValidator);
return new McpSyncServer(asyncServer);
}
diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServerExchange.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServerExchange.java
index 25da5a6f9..bdcc20471 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServerExchange.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServerExchange.java
@@ -5,7 +5,6 @@
package io.modelcontextprotocol.server;
import io.modelcontextprotocol.spec.McpSchema;
-import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
/**
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidator.java b/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidator.java
new file mode 100644
index 000000000..cd8fc9659
--- /dev/null
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidator.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+package io.modelcontextprotocol.spec;
+
+import java.util.Map;
+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.util.Assert;
+
+/**
+ * Default implementation of the {@link JsonSchemaValidator} interface. This class
+ * provides methods to validate structured content against a JSON schema. It uses the
+ * NetworkNT JSON Schema Validator library for validation.
+ *
+ * @author Christian Tzolov
+ */
+public class DefaultJsonSchemaValidator implements JsonSchemaValidator {
+
+ private static final Logger logger = LoggerFactory.getLogger(DefaultJsonSchemaValidator.class);
+
+ private final ObjectMapper objectMapper;
+
+ private final JsonSchemaFactory schemaFactory;
+
+ // TODO: Implement a strategy to purge the cache (TTL, size limit, etc.)
+ private final ConcurrentHashMap schemaCache;
+
+ public DefaultJsonSchemaValidator() {
+ this(new ObjectMapper());
+ }
+
+ public DefaultJsonSchemaValidator(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ this.schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012);
+ this.schemaCache = new ConcurrentHashMap<>();
+ }
+
+ @Override
+ public ValidationResponse validate(Map schema, Map structuredContent) {
+
+ Assert.notNull(schema, "Schema must not be null");
+ Assert.notNull(structuredContent, "Structured content must not be null");
+
+ try {
+
+ JsonNode jsonStructuredOutput = this.objectMapper.valueToTree(structuredContent);
+
+ Set validationResult = this.getOrCreateJsonSchema(schema).validate(jsonStructuredOutput);
+
+ // Check if validation passed
+ if (!validationResult.isEmpty()) {
+ return ValidationResponse
+ .asInvalid("Validation failed: structuredContent does not match tool outputSchema. "
+ + "Validation errors: " + validationResult);
+ }
+
+ return ValidationResponse.asValid(jsonStructuredOutput.toString());
+
+ }
+ catch (JsonProcessingException e) {
+ logger.error("Failed to validate CallToolResult: Error parsing schema: {}", e);
+ return ValidationResponse.asInvalid("Error parsing tool JSON Schema: " + e.getMessage());
+ }
+ catch (Exception e) {
+ logger.error("Failed to validate CallToolResult: Unexpected error: {}", e);
+ return ValidationResponse.asInvalid("Unexpected validation error: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Gets a cached JsonSchema or creates and caches a new one.
+ * @param schema the schema map to convert
+ * @return the compiled JsonSchema
+ * @throws JsonProcessingException if schema processing fails
+ */
+ private JsonSchema getOrCreateJsonSchema(Map schema) throws JsonProcessingException {
+ // Generate cache key based on schema content
+ String cacheKey = this.generateCacheKey(schema);
+
+ // Try to get from cache first
+ JsonSchema cachedSchema = this.schemaCache.get(cacheKey);
+ if (cachedSchema != null) {
+ return cachedSchema;
+ }
+
+ // Create new schema if not in cache
+ JsonSchema newSchema = this.createJsonSchema(schema);
+
+ // Cache the schema
+ JsonSchema existingSchema = this.schemaCache.putIfAbsent(cacheKey, newSchema);
+ return existingSchema != null ? existingSchema : newSchema;
+ }
+
+ /**
+ * Creates a new JsonSchema from the given schema map.
+ * @param schema the schema map
+ * @return the compiled JsonSchema
+ * @throws JsonProcessingException if schema processing fails
+ */
+ private JsonSchema createJsonSchema(Map schema) throws JsonProcessingException {
+ // Convert schema map directly to JsonNode (more efficient than string
+ // serialization)
+ JsonNode schemaNode = this.objectMapper.valueToTree(schema);
+
+ // Handle case where ObjectMapper might return null (e.g., in mocked scenarios)
+ if (schemaNode == null) {
+ throw new JsonProcessingException("Failed to convert schema to JsonNode") {
+ };
+ }
+
+ // Handle additionalProperties setting
+ if (schemaNode.isObject()) {
+ ObjectNode objectSchemaNode = (ObjectNode) schemaNode;
+ if (!objectSchemaNode.has("additionalProperties")) {
+ // Clone the node before modification to avoid mutating the original
+ objectSchemaNode = objectSchemaNode.deepCopy();
+ objectSchemaNode.put("additionalProperties", false);
+ schemaNode = objectSchemaNode;
+ }
+ }
+
+ return this.schemaFactory.getSchema(schemaNode);
+ }
+
+ /**
+ * Generates a cache key for the given schema map.
+ * @param schema the schema map
+ * @return a cache key string
+ */
+ protected String generateCacheKey(Map schema) {
+ if (schema.containsKey("$id")) {
+ // Use the (optional) "$id" field as the cache key if present
+ return "" + schema.get("$id");
+ }
+ // Fall back to schema's hash code as a simple cache key
+ // For more sophisticated caching, could use content-based hashing
+ return String.valueOf(schema.hashCode());
+ }
+
+ /**
+ * Clears the schema cache. Useful for testing or memory management.
+ */
+ public void clearCache() {
+ this.schemaCache.clear();
+ }
+
+ /**
+ * Returns the current size of the schema cache.
+ * @return the number of cached schemas
+ */
+ public int getCacheSize() {
+ return this.schemaCache.size();
+ }
+
+}
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java b/mcp/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java
new file mode 100644
index 000000000..c95e627a9
--- /dev/null
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+package io.modelcontextprotocol.spec;
+
+import java.util.Map;
+
+/**
+ * Interface for validating structured content against a JSON schema. This interface
+ * defines a method to validate structured content based on the provided output schema.
+ *
+ * @author Christian Tzolov
+ */
+public interface JsonSchemaValidator {
+
+ /**
+ * Represents the result of a validation operation.
+ *
+ * @param valid Indicates whether the validation was successful.
+ * @param errorMessage An error message if the validation failed, otherwise null.
+ * @param jsonStructuredOutput The text structured content in JSON format if the
+ * validation was successful, otherwise null.
+ */
+ public record ValidationResponse(boolean valid, String errorMessage, String jsonStructuredOutput) {
+
+ public static ValidationResponse asValid(String jsonStructuredOutput) {
+ return new ValidationResponse(true, null, jsonStructuredOutput);
+ }
+
+ public static ValidationResponse asInvalid(String message) {
+ return new ValidationResponse(false, message, null);
+ }
+ }
+
+ /**
+ * Validates the structured content against the provided JSON schema.
+ * @param schema The JSON schema to validate against.
+ * @param structuredContent The structured content to validate.
+ * @return A ValidationResponse indicating whether the validation was successful or
+ * not.
+ */
+ ValidationResponse validate(Map schema, Map structuredContent);
+
+}
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java
index cd73c0fc9..9ba270dc5 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java
@@ -830,15 +830,25 @@ public record JsonSchema( // @formatter:off
@JsonProperty("definitions") Map definitions) {
} // @formatter:on
+ /**
+ * Additional properties describing a Tool to clients.
+ *
+ * NOTE: all properties in ToolAnnotations are **hints**. They are not guaranteed to
+ * provide a faithful description of tool behavior (including descriptive properties
+ * like `title`).
+ *
+ * Clients should never make tool use decisions based on ToolAnnotations received from
+ * untrusted servers.
+ */
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonIgnoreProperties(ignoreUnknown = true)
public record ToolAnnotations( // @formatter:off
- @JsonProperty("title") String title,
- @JsonProperty("readOnlyHint") Boolean readOnlyHint,
- @JsonProperty("destructiveHint") Boolean destructiveHint,
- @JsonProperty("idempotentHint") Boolean idempotentHint,
- @JsonProperty("openWorldHint") Boolean openWorldHint,
- @JsonProperty("returnDirect") Boolean returnDirect) {
+ @JsonProperty("title") String title,
+ @JsonProperty("readOnlyHint") Boolean readOnlyHint,
+ @JsonProperty("destructiveHint") Boolean destructiveHint,
+ @JsonProperty("idempotentHint") Boolean idempotentHint,
+ @JsonProperty("openWorldHint") Boolean openWorldHint,
+ @JsonProperty("returnDirect") Boolean returnDirect) {
} // @formatter:on
/**
@@ -852,27 +862,45 @@ public record ToolAnnotations( // @formatter:off
* used by clients to improve the LLM's understanding of available tools.
* @param inputSchema A JSON Schema object that describes the expected structure of
* the arguments when calling this tool. This allows clients to validate tool
- * @param annotations Additional properties describing a Tool to clients. arguments
- * before sending them to the server.
+ * @param outputSchema An optional JSON Schema object defining the structure of the
+ * tool's output returned in the structuredContent field of a CallToolResult.
+ * @param annotations Optional additional tool information.
*/
@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("annotations") ToolAnnotations annotations) {
+ @JsonProperty("name") String name,
+ @JsonProperty("description") String description,
+ @JsonProperty("inputSchema") JsonSchema inputSchema,
+ @JsonProperty("outputSchema") Map outputSchema,
+ @JsonProperty("annotations") ToolAnnotations annotations) {
- public Tool(String name, String description, String schema) {
- this(name, description, parseSchema(schema), null);
+ public Tool(String name, String description, JsonSchema inputSchema, ToolAnnotations annotations) {
+ this(name, description, inputSchema, null, annotations);
+ }
+
+ public Tool(String name, String description, String inputSchema) {
+ this(name, description, parseSchema(inputSchema), null, null);
}
public Tool(String name, String description, String schema, ToolAnnotations annotations) {
- this(name, description, parseSchema(schema), annotations);
+ this(name, description, parseSchema(schema), null, annotations);
}
+ public Tool(String name, String description, String inputSchema, String outputSchema, ToolAnnotations annotations) {
+ this(name, description, parseSchema(inputSchema), schemaToMap(outputSchema), annotations);
+ }
} // @formatter:on
+ private static Map schemaToMap(String schema) {
+ try {
+ return OBJECT_MAPPER.readValue(schema, MAP_TYPE_REF);
+ }
+ catch (IOException e) {
+ throw new IllegalArgumentException("Invalid schema: " + schema, e);
+ }
+ }
+
private static JsonSchema parseSchema(String schema) {
try {
return OBJECT_MAPPER.readValue(schema, JsonSchema.class);
@@ -917,12 +945,19 @@ private static Map parseJsonArguments(String jsonArguments) {
* 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.
+ * @param structuredContent An optional JSON object that represents the structured result of the tool call.
*/
@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) {
+
+ // backwards compatibility constructor
+ public CallToolResult(List content, Boolean isError) {
+ this(content, isError, null);
+ }
/**
* Creates a new instance of {@link CallToolResult} with a string containing the
@@ -950,7 +985,8 @@ public static Builder builder() {
*/
public static class Builder {
private List content = new ArrayList<>();
- private Boolean isError;
+ private Boolean isError = false;
+ private Map structuredContent;
/**
* Sets the content list for the tool result.
@@ -963,6 +999,22 @@ public Builder content(List content) {
return this;
}
+ public Builder structuredContent(Map structuredContent) {
+ Assert.notNull(structuredContent, "structuredContent must not be null");
+ this.structuredContent = structuredContent;
+ return this;
+ }
+
+ public Builder structuredContent(String structuredContent) {
+ Assert.hasText(structuredContent, "structuredContent must not be empty");
+ try {
+ this.structuredContent = OBJECT_MAPPER.readValue(structuredContent, MAP_TYPE_REF);
+ } catch (IOException e) {
+ throw new IllegalArgumentException("Invalid structured content: " + structuredContent, e);
+ }
+ return this;
+ }
+
/**
* Sets the text content for the tool result.
* @param textContent the text content
@@ -1016,7 +1068,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);
}
}
@@ -1246,7 +1298,7 @@ public CreateMessageResult build() {
/**
* Used by the server to send an elicitation to the client.
*
- * @param message The body of the elicitation message.
+ * @param errorMessage The body of the elicitation message.
* @param requestedSchema The elicitation response schema that must be satisfied.
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
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..ff28c2191 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProviderIntegrationTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProviderIntegrationTests.java
@@ -32,6 +32,8 @@
import io.modelcontextprotocol.spec.McpSchema.Root;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.Tool;
+import net.javacrumbs.jsonunit.core.Option;
+
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.apache.catalina.startup.Tomcat;
@@ -48,6 +50,8 @@
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.mock;
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
class HttpServletSseServerTransportProviderIntegrationTests {
@@ -958,4 +962,231 @@ void testLoggingNotification() {
mcpServer.close();
}
+ // ---------------------------------------
+ // Tool Structured Output Schema Tests
+ // ---------------------------------------
+
+ @Test
+ void testStructuredOutputValidationSuccess() {
+ // Create a tool with output schema
+ Map outputSchema = Map.of(
+ "type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
+ Map.of("type", "string"), "timestamp", Map.of("type", "string")),
+ "required", List.of("result", "operation"));
+
+ Tool calculatorTool = new Tool("calculator", "Performs mathematical calculations", (McpSchema.JsonSchema) null,
+ outputSchema, (McpSchema.ToolAnnotations) null);
+
+ McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
+ (exchange, request) -> {
+ String expression = (String) request.getOrDefault("expression", "2 + 3");
+ double result = evaluateExpression(expression);
+ return CallToolResult.builder()
+ .structuredContent(
+ Map.of("result", result, "operation", expression, "timestamp", "2024-01-01T10:00:00Z"))
+ .build();
+ });
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tools(tool)
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Verify tool is listed with output schema
+ var toolsList = mcpClient.listTools();
+ assertThat(toolsList.tools()).hasSize(1);
+ assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator");
+ // Note: outputSchema might be null in sync server, but validation still works
+
+ // Call tool with valid structured output
+ CallToolResult response = mcpClient
+ .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
+
+ assertThat(response).isNotNull();
+ assertThat(response.isError()).isFalse();
+ assertThat(response.content()).hasSize(1);
+ assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
+
+ assertThatJson(((McpSchema.TextContent) response.content().get(0)).text()).when(Option.IGNORING_ARRAY_ORDER)
+ .when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
+ .isObject()
+ .isEqualTo(json("""
+ {"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}"""));
+
+ // Verify structured content (may be null in sync server but validation still
+ // works)
+ if (response.structuredContent() != null) {
+ assertThat(response.structuredContent()).containsEntry("result", 5.0)
+ .containsEntry("operation", "2 + 3")
+ .containsEntry("timestamp", "2024-01-01T10:00:00Z");
+ }
+ }
+
+ mcpServer.close();
+ }
+
+ @Test
+ void testStructuredOutputValidationFailure() {
+
+ // Create a tool with output schema
+ Map outputSchema = Map.of("type", "object", "properties",
+ Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required",
+ List.of("result", "operation"));
+
+ Tool calculatorTool = new Tool("calculator", "Performs mathematical calculations", (McpSchema.JsonSchema) null,
+ outputSchema, (McpSchema.ToolAnnotations) null);
+
+ McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
+ (exchange, request) -> {
+ // Return invalid structured output. Result should be number, missing
+ // operation
+ return CallToolResult.builder()
+ .addTextContent("Invalid calculation")
+ .structuredContent(Map.of("result", "not-a-number", "extra", "field"))
+ .build();
+ });
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tools(tool)
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Call tool with invalid structured output
+ CallToolResult response = mcpClient
+ .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
+
+ assertThat(response).isNotNull();
+ assertThat(response.isError()).isTrue();
+ assertThat(response.content()).hasSize(1);
+ assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
+
+ String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
+ assertThat(errorMessage).contains("Validation failed");
+ }
+
+ mcpServer.close();
+ }
+
+ @Test
+ void testStructuredOutputMissingStructuredContent() {
+ // Create a tool with output schema
+ Map outputSchema = Map.of("type", "object", "properties",
+ Map.of("result", Map.of("type", "number")), "required", List.of("result"));
+
+ Tool calculatorTool = new Tool("calculator", "Performs mathematical calculations", (McpSchema.JsonSchema) null,
+ outputSchema, (McpSchema.ToolAnnotations) null);
+
+ McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
+ (exchange, request) -> {
+ // Return result without structured content but tool has output schema
+ return CallToolResult.builder().addTextContent("Calculation completed").build();
+ });
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tools(tool)
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Call tool that should return structured content but doesn't
+ CallToolResult response = mcpClient
+ .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
+
+ assertThat(response).isNotNull();
+ assertThat(response.isError()).isTrue();
+ assertThat(response.content()).hasSize(1);
+ assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
+
+ String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
+ assertThat(errorMessage).isEqualTo(
+ "Response missing structured content which is expected when calling tool with non-empty outputSchema");
+ }
+
+ mcpServer.close();
+ }
+
+ @Test
+ void testStructuredOutputRuntimeToolAddition() {
+ // Start server without tools
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Initially no tools
+ assertThat(mcpClient.listTools().tools()).isEmpty();
+
+ // Add tool with output schema at runtime
+ Map outputSchema = Map.of("type", "object", "properties",
+ Map.of("message", Map.of("type", "string"), "count", Map.of("type", "integer")), "required",
+ List.of("message", "count"));
+
+ Tool dynamicTool = new Tool("dynamic-tool", "Dynamically added tool", (McpSchema.JsonSchema) null,
+ outputSchema, (McpSchema.ToolAnnotations) null);
+
+ McpServerFeatures.SyncToolSpecification toolSpec = new McpServerFeatures.SyncToolSpecification(dynamicTool,
+ (exchange, request) -> {
+ int count = (Integer) request.getOrDefault("count", 1);
+ return CallToolResult.builder()
+ .addTextContent("Dynamic tool executed " + count + " times")
+ .structuredContent(Map.of("message", "Dynamic execution", "count", count))
+ .build();
+ });
+
+ // Add tool to server
+ mcpServer.addTool(toolSpec);
+
+ // Wait for tool list change notification
+ await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
+ assertThat(mcpClient.listTools().tools()).hasSize(1);
+ });
+
+ // Verify tool was added with output schema
+ var toolsList = mcpClient.listTools();
+ assertThat(toolsList.tools()).hasSize(1);
+ assertThat(toolsList.tools().get(0).name()).isEqualTo("dynamic-tool");
+ // Note: outputSchema might be null in sync server, but validation still works
+
+ // Call dynamically added tool
+ CallToolResult response = mcpClient
+ .callTool(new McpSchema.CallToolRequest("dynamic-tool", Map.of("count", 3)));
+
+ assertThat(response).isNotNull();
+ assertThat(response.isError()).isFalse();
+ assertThat(response.structuredContent()).containsEntry("message", "Dynamic execution")
+ .containsEntry("count", 3);
+ }
+
+ mcpServer.close();
+ }
+
+ private double evaluateExpression(String expression) {
+ // Simple expression evaluator for testing
+ return switch (expression) {
+ case "2 + 3" -> 5.0;
+ case "10 * 2" -> 20.0;
+ case "7 + 8" -> 15.0;
+ case "5 + 3" -> 8.0;
+ default -> 0.0;
+ };
+ }
+
}
diff --git a/mcp/src/test/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidatorTests.java b/mcp/src/test/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidatorTests.java
new file mode 100644
index 000000000..9da31b38b
--- /dev/null
+++ b/mcp/src/test/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidatorTests.java
@@ -0,0 +1,698 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+package io.modelcontextprotocol.spec;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import java.util.Map;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import io.modelcontextprotocol.spec.DefaultJsonSchemaValidator;
+import io.modelcontextprotocol.spec.JsonSchemaValidator.ValidationResponse;
+
+/**
+ * Tests for {@link DefaultJsonSchemaValidator}.
+ *
+ * @author Christian Tzolov
+ */
+class DefaultJsonSchemaValidatorTests {
+
+ private DefaultJsonSchemaValidator validator;
+
+ private ObjectMapper objectMapper;
+
+ @Mock
+ private ObjectMapper mockObjectMapper;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ validator = new DefaultJsonSchemaValidator();
+ objectMapper = new ObjectMapper();
+ }
+
+ /**
+ * Utility method to convert JSON string to Map
+ */
+ private Map toMap(String json) {
+ try {
+ return objectMapper.readValue(json, new TypeReference