From 7ce1552c17acb7f1b622ce07bab4034edd08dc8d Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Mon, 30 Jun 2025 14:08:50 +0200 Subject: [PATCH 1/3] feat: add structured output support for MCP tools - Add JsonSchemaValidator interface and DefaultJsonSchemaValidator implementation - Extend Tool schema to support outputSchema field for defining expected output structure - Add structuredContent field to CallToolResult for validated structured responses - Implement automatic validation of tool outputs against their defined schemas - Add comprehensive test coverage for structured output validation scenarios - Add json-schema-validator and json-unit-assertj dependencies for validation and testing - Update McpServer builders to accept custom JsonSchemaValidator instances - Ensure backward compatibility with existing tools without output schemas This implements the MCP specification requirement that tools with output schemas must provide structured results conforming to those schemas, with automatic validation and error handling for non-conforming outputs. https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content Signed-off-by: Christian Tzolov --- mcp-spring/mcp-spring-webflux/pom.xml | 7 + .../WebFluxSseIntegrationTests.java | 252 ++++++- mcp-spring/mcp-spring-webmvc/pom.xml | 9 +- .../server/WebMvcSseIntegrationTests.java | 242 ++++++ mcp/pom.xml | 9 + .../server/DefaultJsonSchemaValidator.java | 82 ++ .../server/JsonSchemaValidator.java | 44 ++ .../server/McpAsyncServer.java | 154 +++- .../server/McpServer.java | 33 +- .../server/McpSyncServerExchange.java | 1 - .../modelcontextprotocol/spec/McpSchema.java | 90 ++- .../DefaultJsonSchemaValidatorTests.java | 698 ++++++++++++++++++ ...rverTransportProviderIntegrationTests.java | 231 ++++++ .../spec/McpSchemaTests.java | 231 +++++- pom.xml | 1 + 15 files changed, 2048 insertions(+), 36 deletions(-) create mode 100644 mcp/src/main/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidator.java create mode 100644 mcp/src/main/java/io/modelcontextprotocol/server/JsonSchemaValidator.java create mode 100644 mcp/src/test/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidatorTests.java 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..71711086a 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("Tool call with non-empty outputSchema must have a result with structured content"); + } + + 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..111c89f79 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("Tool call with non-empty outputSchema must have a result with structured content"); + } + + 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..931f81301 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -83,6 +83,15 @@ reactor-core + + + com.networknt + json-schema-validator + ${json-schema-validator.version} + + org.springframework spring-webmvc diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidator.java b/mcp/src/main/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidator.java new file mode 100644 index 000000000..2ec9e87ad --- /dev/null +++ b/mcp/src/main/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidator.java @@ -0,0 +1,82 @@ +/* + * Copyright 2024-2024 the original author or authors. + */ +package io.modelcontextprotocol.server; + +import java.util.Map; +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; + +/** + * 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 ObjectMapper objectMapper = new ObjectMapper(); + + public DefaultJsonSchemaValidator() { + this.objectMapper = new ObjectMapper(); + } + + public DefaultJsonSchemaValidator(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public ValidationResponse validate(Map schema, Map structuredContent) { + + try { + // Create JsonSchema validator + ObjectNode schemaNode = (ObjectNode) this.objectMapper + .readTree(this.objectMapper.writeValueAsString(schema)); + + // Set additional properties to false if not specified in the schema + if (!schemaNode.has("additionalProperties")) { + schemaNode.put("additionalProperties", false); + } + + JsonSchema jsonSchema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(schemaNode); + + // Convert structured content in reult to JsonNode + JsonNode jsonStructuredOutput = this.objectMapper.valueToTree(structuredContent); + + // Validate outputSchema against structuredContent + Set validationResult = jsonSchema.validate(jsonStructuredOutput); + + // Check if validation passed + if (!validationResult.isEmpty()) { + logger.warn("Validation failed: structuredContent does not match tool outputSchema. " + + "Validation errors: {}", validationResult); + return ValidationResponse + .asInvalid("Validation failed: structuredContent does not match tool outputSchema. " + + "Validation errors: " + validationResult); + } + + return ValidationResponse.asValid(jsonStructuredOutput.toString()); + + } + catch (JsonProcessingException e) { + logger.warn("Failed to validate CallToolResult: Error parsing schema: {}", e); + return ValidationResponse.asInvalid("Error parsing tool JSON Schema: " + e.getMessage()); + } + } + +} diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/JsonSchemaValidator.java b/mcp/src/main/java/io/modelcontextprotocol/server/JsonSchemaValidator.java new file mode 100644 index 000000000..6f1857308 --- /dev/null +++ b/mcp/src/main/java/io/modelcontextprotocol/server/JsonSchemaValidator.java @@ -0,0 +1,44 @@ +/* + * Copyright 2024-2024 the original author or authors. + */ +package io.modelcontextprotocol.server; + +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/server/McpAsyncServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index 02ad955b9..707a12d29 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -15,8 +15,12 @@ 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.McpClientSession; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; @@ -28,11 +32,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 +89,8 @@ public class McpAsyncServer { private final ObjectMapper objectMapper; + private final JsonSchemaValidator jsonSchemaValidator; + private final McpSchema.ServerCapabilities serverCapabilities; private final McpSchema.Implementation serverInfo; @@ -119,18 +124,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 +292,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 +311,134 @@ public Mono addTool(McpServerFeatures.AsyncToolSpecification toolSpecifica }); } + 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("Tool call with non-empty outputSchema MUST have a result with structured content"); + // if (!Utils.isEmpty(result.content())) { + // // TODO If the tesult.content() contains json text we can try to + // // convert it into a structured content (Experimental) + // var tc = result.content().stream().filter(c -> c instanceof + // McpSchema.TextContent).findFirst(); + // if (tc.isPresent()) { + // try { + // Map structuredOutput = new + // ObjectMapper().readValue( + // ((TextContent) tc.get()).text(), new TypeReference>() { + // }); + + // // Overwrite the result with the structured content + // // generated from the text content. + // result = new CallToolResult(result.content(), result.isError(), + // structuredOutput); + + // } + // catch (Exception e) { + // logger.warn("Failed to parse text content as structured content: + // {}", e.getMessage()); + // return new CallToolResult( + // "Failed to parse text content as structured content: " + + // e.getMessage(), true); + // } + + // } + // } + return new CallToolResult( + "Tool call with non-empty outputSchema must have a result with structured content", 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; + }); + } + + } + + static List withStructuredOutputHandling( + JsonSchemaValidator jsonSchemaValidator, List tools) { + + if (Utils.isEmpty(tools)) { + return tools; + } + + return tools.stream().map(tool -> withStructuredOutputHandling(jsonSchemaValidator, tool)).toList(); + } + + 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..8056b0b75 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -166,6 +166,8 @@ class AsyncSpecification { private McpSchema.ServerCapabilities serverCapabilities; + private JsonSchemaValidator jsonSchemaValidator; + private String instructions; /** @@ -624,6 +626,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 +650,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 +698,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 +1136,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 +1153,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/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/DefaultJsonSchemaValidatorTests.java b/mcp/src/test/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidatorTests.java new file mode 100644 index 000000000..10f2307ed --- /dev/null +++ b/mcp/src/test/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidatorTests.java @@ -0,0 +1,698 @@ +/* + * Copyright 2024-2024 the original author or authors. + */ +package io.modelcontextprotocol.server; + +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.server.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>() { + }); + } + catch (Exception e) { + throw new RuntimeException("Failed to parse JSON: " + json, e); + } + } + + @Test + void testDefaultConstructor() { + DefaultJsonSchemaValidator defaultValidator = new DefaultJsonSchemaValidator(); + + String schemaJson = """ + { + "type": "object", + "properties": { + "test": {"type": "string"} + } + } + """; + String contentJson = """ + { + "test": "value" + } + """; + + ValidationResponse response = defaultValidator.validate(toMap(schemaJson), toMap(contentJson)); + assertTrue(response.valid()); + } + + @Test + void testConstructorWithObjectMapper() { + ObjectMapper customMapper = new ObjectMapper(); + DefaultJsonSchemaValidator customValidator = new DefaultJsonSchemaValidator(customMapper); + + String schemaJson = """ + { + "type": "object", + "properties": { + "test": {"type": "string"} + } + } + """; + String contentJson = """ + { + "test": "value" + } + """; + + ValidationResponse response = customValidator.validate(toMap(schemaJson), toMap(contentJson)); + assertTrue(response.valid()); + } + + @Test + void testValidateWithValidStringSchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"} + }, + "required": ["name", "age"] + } + """; + + String contentJson = """ + { + "name": "John Doe", + "age": 30 + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + assertNotNull(response.jsonStructuredOutput()); + } + + @Test + void testValidateWithValidNumberSchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "price": {"type": "number", "minimum": 0}, + "quantity": {"type": "integer", "minimum": 1} + }, + "required": ["price", "quantity"] + } + """; + + String contentJson = """ + { + "price": 19.99, + "quantity": 5 + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithValidArraySchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {"type": "string"} + } + }, + "required": ["items"] + } + """; + + String contentJson = """ + { + "items": ["apple", "banana", "cherry"] + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithInvalidTypeSchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"} + }, + "required": ["name", "age"] + } + """; + + String contentJson = """ + { + "name": "John Doe", + "age": "thirty" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + assertTrue(response.errorMessage().contains("structuredContent does not match tool outputSchema")); + } + + @Test + void testValidateWithMissingRequiredField() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"} + }, + "required": ["name", "age"] + } + """; + + String contentJson = """ + { + "name": "John Doe" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + } + + @Test + void testValidateWithAdditionalPropertiesNotAllowed() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"] + } + """; + + String contentJson = """ + { + "name": "John Doe", + "extraField": "should not be allowed" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + } + + @Test + void testValidateWithAdditionalPropertiesExplicitlyAllowed() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"], + "additionalProperties": true + } + """; + + String contentJson = """ + { + "name": "John Doe", + "extraField": "should be allowed" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithAdditionalPropertiesExplicitlyDisallowed() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"], + "additionalProperties": false + } + """; + + String contentJson = """ + { + "name": "John Doe", + "extraField": "should not be allowed" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + } + + @Test + void testValidateWithEmptySchema() { + String schemaJson = """ + { + "additionalProperties": true + } + """; + + String contentJson = """ + { + "anything": "goes" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithEmptyContent() { + String schemaJson = """ + { + "type": "object", + "properties": {} + } + """; + + String contentJson = """ + {} + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithNestedObjectSchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "person": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"} + }, + "required": ["street", "city"] + } + }, + "required": ["name", "address"] + } + }, + "required": ["person"] + } + """; + + String contentJson = """ + { + "person": { + "name": "John Doe", + "address": { + "street": "123 Main St", + "city": "Anytown" + } + } + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithInvalidNestedObjectSchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "person": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"} + }, + "required": ["street", "city"] + } + }, + "required": ["name", "address"] + } + }, + "required": ["person"] + } + """; + + String contentJson = """ + { + "person": { + "name": "John Doe", + "address": { + "street": "123 Main St" + } + } + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + } + + @Test + void testValidateWithJsonProcessingException() throws Exception { + DefaultJsonSchemaValidator validatorWithMockMapper = new DefaultJsonSchemaValidator(mockObjectMapper); + + Map schema = Map.of("type", "object"); + Map structuredContent = Map.of("key", "value"); + + when(mockObjectMapper.writeValueAsString(any())) + .thenThrow(new com.fasterxml.jackson.core.JsonProcessingException("Mock JSON processing error") { + }); + + ValidationResponse response = validatorWithMockMapper.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Error parsing tool JSON Schema")); + assertTrue(response.errorMessage().contains("Mock JSON processing error")); + } + + @ParameterizedTest + @MethodSource("provideValidSchemaAndContentPairs") + void testValidateWithVariousValidInputs(Map schema, Map content) { + ValidationResponse response = validator.validate(schema, content); + + assertTrue(response.valid(), "Expected validation to pass for schema: " + schema + " and content: " + content); + assertNull(response.errorMessage()); + } + + @ParameterizedTest + @MethodSource("provideInvalidSchemaAndContentPairs") + void testValidateWithVariousInvalidInputs(Map schema, Map content) { + ValidationResponse response = validator.validate(schema, content); + + assertFalse(response.valid(), "Expected validation to fail for schema: " + schema + " and content: " + content); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + } + + private static Map staticToMap(String json) { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(json, new TypeReference>() { + }); + } + catch (Exception e) { + throw new RuntimeException("Failed to parse JSON: " + json, e); + } + } + + private static Stream provideValidSchemaAndContentPairs() { + return Stream.of( + // Boolean schema + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "flag": {"type": "boolean"} + } + } + """), staticToMap(""" + { + "flag": true + } + """)), + // String with additional properties allowed + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": true + } + """), staticToMap(""" + { + "name": "test", + "extra": "allowed" + } + """)), + // Array with specific items + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "numbers": { + "type": "array", + "items": {"type": "number"} + } + } + } + """), staticToMap(""" + { + "numbers": [1.0, 2.5, 3.14] + } + """)), + // Enum validation + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["active", "inactive", "pending"] + } + } + } + """), staticToMap(""" + { + "status": "active" + } + """))); + } + + private static Stream provideInvalidSchemaAndContentPairs() { + return Stream.of( + // Wrong boolean type + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "flag": {"type": "boolean"} + } + } + """), staticToMap(""" + { + "flag": "true" + } + """)), + // Array with wrong item types + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "numbers": { + "type": "array", + "items": {"type": "number"} + } + } + } + """), staticToMap(""" + { + "numbers": ["one", "two", "three"] + } + """)), + // Invalid enum value + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["active", "inactive", "pending"] + } + } + } + """), staticToMap(""" + { + "status": "unknown" + } + """)), + // Minimum constraint violation + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "age": {"type": "integer", "minimum": 0} + } + } + """), staticToMap(""" + { + "age": -5 + } + """))); + } + + @Test + void testValidationResponseToValid() { + String jsonOutput = "{\"test\":\"value\"}"; + ValidationResponse response = ValidationResponse.asValid(jsonOutput); + assertTrue(response.valid()); + assertNull(response.errorMessage()); + assertEquals(jsonOutput, response.jsonStructuredOutput()); + } + + @Test + void testValidationResponseToInvalid() { + String errorMessage = "Test error message"; + ValidationResponse response = ValidationResponse.asInvalid(errorMessage); + assertFalse(response.valid()); + assertEquals(errorMessage, response.errorMessage()); + assertNull(response.jsonStructuredOutput()); + } + + @Test + void testValidationResponseRecord() { + ValidationResponse response1 = new ValidationResponse(true, null, "{\"valid\":true}"); + ValidationResponse response2 = new ValidationResponse(false, "Error", null); + + assertTrue(response1.valid()); + assertNull(response1.errorMessage()); + assertEquals("{\"valid\":true}", response1.jsonStructuredOutput()); + + assertFalse(response2.valid()); + assertEquals("Error", response2.errorMessage()); + assertNull(response2.jsonStructuredOutput()); + + // Test equality + ValidationResponse response3 = new ValidationResponse(true, null, "{\"valid\":true}"); + assertEquals(response1, response3); + assertNotEquals(response1, response2); + } + +} 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..c0c48318b 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("Tool call with non-empty outputSchema must have a result with structured content"); + } + + 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/McpSchemaTests.java b/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 5b76ff09d..b4e16b2b8 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -734,9 +734,234 @@ void testToolWithAnnotations() throws Exception { assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) .isObject() - .isEqualTo( - json(""" - {"name":"test-tool","description":"A test tool","inputSchema":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"number"}},"required":["name"]},"annotations":{"title":"A test tool","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"returnDirect":false}}""")); + .isEqualTo(json(""" + { + "name":"test-tool", + "description":"A test tool", + "inputSchema":{ + "type":"object", + "properties":{ + "name":{"type":"string"}, + "value":{"type":"number"} + }, + "required":["name"] + }, + "annotations":{ + "title":"A test tool", + "readOnlyHint":false, + "destructiveHint":false, + "idempotentHint":false, + "openWorldHint":false, + "returnDirect":false + } + } + """)); + } + + @Test + void testToolWithOutputSchema() throws Exception { + String inputSchemaJson = """ + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "required": ["name"] + } + """; + + String outputSchemaJson = """ + { + "type": "object", + "properties": { + "result": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["success", "error"] + } + }, + "required": ["result", "status"] + } + """; + + McpSchema.Tool tool = new McpSchema.Tool("test-tool", "A test tool", inputSchemaJson, outputSchemaJson, null); + + String value = mapper.writeValueAsString(tool); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + { + "name":"test-tool", + "description":"A test tool", + "inputSchema":{ + "type":"object", + "properties":{ + "name":{"type":"string"}, + "value":{"type":"number"} + }, + "required":["name"] + }, + "outputSchema":{ + "type":"object", + "properties":{ + "result":{"type":"string"}, + "status":{ + "type":"string", + "enum":["success","error"] + } + }, + "required":["result","status"] + } + } + """)); + } + + @Test + void testToolWithOutputSchemaAndAnnotations() throws Exception { + String inputSchemaJson = """ + { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + """; + + String outputSchemaJson = """ + { + "type": "object", + "properties": { + "result": { + "type": "string" + } + }, + "required": ["result"] + } + """; + + McpSchema.ToolAnnotations annotations = new McpSchema.ToolAnnotations("A test tool with output", true, false, + true, false, true); + + McpSchema.Tool tool = new McpSchema.Tool("test-tool", "A test tool", inputSchemaJson, outputSchemaJson, + annotations); + + String value = mapper.writeValueAsString(tool); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + { + "name":"test-tool", + "description":"A test tool", + "inputSchema":{ + "type":"object", + "properties":{ + "name":{"type":"string"} + }, + "required":["name"] + }, + "outputSchema":{ + "type":"object", + "properties":{ + "result":{"type":"string"} + }, + "required":["result"] + }, + "annotations":{ + "title":"A test tool with output", + "readOnlyHint":true, + "destructiveHint":false, + "idempotentHint":true, + "openWorldHint":false, + "returnDirect":true + } + }""")); + } + + @Test + void testToolDeserialization() throws Exception { + String toolJson = """ + { + "name": "test-tool", + "description": "A test tool", + "inputSchema": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"] + }, + "outputSchema": { + "type": "object", + "properties": { + "result": {"type": "string"} + }, + "required": ["result"] + }, + "annotations": { + "title": "Test Tool", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "returnDirect": false + } + } + """; + + McpSchema.Tool tool = mapper.readValue(toolJson, McpSchema.Tool.class); + + assertThat(tool).isNotNull(); + assertThat(tool.name()).isEqualTo("test-tool"); + assertThat(tool.description()).isEqualTo("A test tool"); + assertThat(tool.inputSchema()).isNotNull(); + assertThat(tool.inputSchema().type()).isEqualTo("object"); + assertThat(tool.outputSchema()).isNotNull(); + assertThat(tool.outputSchema()).containsKey("type"); + assertThat(tool.outputSchema().get("type")).isEqualTo("object"); + assertThat(tool.annotations()).isNotNull(); + assertThat(tool.annotations().title()).isEqualTo("Test Tool"); + assertThat(tool.annotations().readOnlyHint()).isTrue(); + assertThat(tool.annotations().idempotentHint()).isTrue(); + assertThat(tool.annotations().destructiveHint()).isFalse(); + assertThat(tool.annotations().returnDirect()).isFalse(); + } + + @Test + void testToolDeserializationWithoutOutputSchema() throws Exception { + String toolJson = """ + { + "name": "test-tool", + "description": "A test tool", + "inputSchema": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"] + } + } + """; + + McpSchema.Tool tool = mapper.readValue(toolJson, McpSchema.Tool.class); + + assertThat(tool).isNotNull(); + assertThat(tool.name()).isEqualTo("test-tool"); + assertThat(tool.description()).isEqualTo("A test tool"); + assertThat(tool.inputSchema()).isNotNull(); + assertThat(tool.outputSchema()).isNull(); + assertThat(tool.annotations()).isNull(); } @Test diff --git a/pom.xml b/pom.xml index 3fd0857e8..b7a66aeec 100644 --- a/pom.xml +++ b/pom.xml @@ -96,6 +96,7 @@ 4.2.0 7.1.0 4.1.0 + 1.5.7 From 5458422adec1570e447c3c04935fc974ca17d9aa Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Wed, 2 Jul 2025 09:46:37 +0200 Subject: [PATCH 2/3] refactor: improve JSON schema validator with caching and reorganize dependencies - Add schema caching to DefaultJsonSchemaValidator for better performance - Make validator fields final and improve constructor design Signed-off-by: Christian Tzolov --- .../WebFluxSseIntegrationTests.java | 4 +- .../server/WebMvcSseIntegrationTests.java | 4 +- mcp/pom.xml | 22 ++- .../server/DefaultJsonSchemaValidator.java | 125 +++++++++++++++--- .../server/McpAsyncServer.java | 41 +----- .../DefaultJsonSchemaValidatorTests.java | 7 +- ...rverTransportProviderIntegrationTests.java | 4 +- 7 files changed, 132 insertions(+), 75 deletions(-) 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 71711086a..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 @@ -1184,8 +1184,8 @@ void testStructuredOutputMissingStructuredContent(String clientType) { assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class); String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text(); - assertThat(errorMessage) - .isEqualTo("Tool call with non-empty outputSchema must have a result with structured content"); + assertThat(errorMessage).isEqualTo( + "Response missing structured content which is expected when calling tool with non-empty outputSchema"); } mcpServer.close(); 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 111c89f79..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 @@ -1017,8 +1017,8 @@ void testStructuredOutputMissingStructuredContent() { assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class); String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text(); - assertThat(errorMessage) - .isEqualTo("Tool call with non-empty outputSchema must have a result with structured content"); + assertThat(errorMessage).isEqualTo( + "Response missing structured content which is expected when calling tool with non-empty outputSchema"); } mcpServer.close(); diff --git a/mcp/pom.xml b/mcp/pom.xml index 931f81301..f49cbdb9a 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -83,15 +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 @@ -188,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/DefaultJsonSchemaValidator.java b/mcp/src/main/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidator.java index 2ec9e87ad..8a5b366ff 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidator.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidator.java @@ -5,6 +5,7 @@ import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,6 +19,8 @@ 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 @@ -29,42 +32,37 @@ public class DefaultJsonSchemaValidator implements JsonSchemaValidator { private static final Logger logger = LoggerFactory.getLogger(DefaultJsonSchemaValidator.class); - private ObjectMapper objectMapper = new ObjectMapper(); + 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.objectMapper = new ObjectMapper(); + 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) { - try { - // Create JsonSchema validator - ObjectNode schemaNode = (ObjectNode) this.objectMapper - .readTree(this.objectMapper.writeValueAsString(schema)); + Assert.notNull(schema, "Schema must not be null"); + Assert.notNull(structuredContent, "Structured content must not be null"); - // Set additional properties to false if not specified in the schema - if (!schemaNode.has("additionalProperties")) { - schemaNode.put("additionalProperties", false); - } - - JsonSchema jsonSchema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) - .getSchema(schemaNode); + try { - // Convert structured content in reult to JsonNode JsonNode jsonStructuredOutput = this.objectMapper.valueToTree(structuredContent); - // Validate outputSchema against structuredContent - Set validationResult = jsonSchema.validate(jsonStructuredOutput); + Set validationResult = this.getOrCreateJsonSchema(schema).validate(jsonStructuredOutput); // Check if validation passed if (!validationResult.isEmpty()) { - logger.warn("Validation failed: structuredContent does not match tool outputSchema. " - + "Validation errors: {}", validationResult); return ValidationResponse .asInvalid("Validation failed: structuredContent does not match tool outputSchema. " + "Validation errors: " + validationResult); @@ -74,9 +72,98 @@ public ValidationResponse validate(Map schema, 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/server/McpAsyncServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index 707a12d29..06223fec2 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -311,7 +311,7 @@ public Mono addTool(McpServerFeatures.AsyncToolSpecification toolSpecifica }); } - static class StructuredOutputCallToolHandler + private static class StructuredOutputCallToolHandler implements BiFunction, Mono> { private final BiFunction, Mono> delegateCallToolResult; @@ -352,38 +352,11 @@ public Mono apply(McpAsyncServerExchange exchange, Map c instanceof - // McpSchema.TextContent).findFirst(); - // if (tc.isPresent()) { - // try { - // Map structuredOutput = new - // ObjectMapper().readValue( - // ((TextContent) tc.get()).text(), new TypeReference>() { - // }); - - // // Overwrite the result with the structured content - // // generated from the text content. - // result = new CallToolResult(result.content(), result.isError(), - // structuredOutput); - - // } - // catch (Exception e) { - // logger.warn("Failed to parse text content as structured content: - // {}", e.getMessage()); - // return new CallToolResult( - // "Failed to parse text content as structured content: " + - // e.getMessage(), true); - // } - - // } - // } + logger.warn( + "Response missing structured content which is expected when calling tool with non-empty outputSchema"); return new CallToolResult( - "Tool call with non-empty outputSchema must have a result with structured content", true); + "Response missing structured content which is expected when calling tool with non-empty outputSchema", + true); } // Validate the result against the output schema @@ -411,7 +384,7 @@ public Mono apply(McpAsyncServerExchange exchange, Map withStructuredOutputHandling( + private static List withStructuredOutputHandling( JsonSchemaValidator jsonSchemaValidator, List tools) { if (Utils.isEmpty(tools)) { @@ -421,7 +394,7 @@ static List withStructuredOutputHandli return tools.stream().map(tool -> withStructuredOutputHandling(jsonSchemaValidator, tool)).toList(); } - static McpServerFeatures.AsyncToolSpecification withStructuredOutputHandling( + private static McpServerFeatures.AsyncToolSpecification withStructuredOutputHandling( JsonSchemaValidator jsonSchemaValidator, McpServerFeatures.AsyncToolSpecification toolSpecification) { if (toolSpecification.call() instanceof StructuredOutputCallToolHandler) { diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidatorTests.java b/mcp/src/test/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidatorTests.java index 10f2307ed..a4cd30da9 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidatorTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidatorTests.java @@ -490,16 +490,15 @@ void testValidateWithJsonProcessingException() throws Exception { Map schema = Map.of("type", "object"); Map structuredContent = Map.of("key", "value"); - when(mockObjectMapper.writeValueAsString(any())) - .thenThrow(new com.fasterxml.jackson.core.JsonProcessingException("Mock JSON processing error") { - }); + // This will trigger our null check and throw JsonProcessingException + when(mockObjectMapper.valueToTree(any())).thenReturn(null); ValidationResponse response = validatorWithMockMapper.validate(schema, structuredContent); assertFalse(response.valid()); assertNotNull(response.errorMessage()); assertTrue(response.errorMessage().contains("Error parsing tool JSON Schema")); - assertTrue(response.errorMessage().contains("Mock JSON processing error")); + assertTrue(response.errorMessage().contains("Failed to convert schema to JsonNode")); } @ParameterizedTest 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 c0c48318b..ff28c2191 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProviderIntegrationTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProviderIntegrationTests.java @@ -1112,8 +1112,8 @@ void testStructuredOutputMissingStructuredContent() { assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class); String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text(); - assertThat(errorMessage) - .isEqualTo("Tool call with non-empty outputSchema must have a result with structured content"); + assertThat(errorMessage).isEqualTo( + "Response missing structured content which is expected when calling tool with non-empty outputSchema"); } mcpServer.close(); From 6a19900818a847023cf0851a9bf1464fe3f7d749 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Wed, 2 Jul 2025 11:09:50 +0200 Subject: [PATCH 3/3] refactor: move JsonSchemaValidator classes from server to spec package Signed-off-by: Christian Tzolov --- .../java/io/modelcontextprotocol/server/McpAsyncServer.java | 1 + .../main/java/io/modelcontextprotocol/server/McpServer.java | 3 +++ .../{server => spec}/DefaultJsonSchemaValidator.java | 2 +- .../{server => spec}/JsonSchemaValidator.java | 2 +- .../{server => spec}/DefaultJsonSchemaValidatorTests.java | 5 +++-- 5 files changed, 9 insertions(+), 4 deletions(-) rename mcp/src/main/java/io/modelcontextprotocol/{server => spec}/DefaultJsonSchemaValidator.java (99%) rename mcp/src/main/java/io/modelcontextprotocol/{server => spec}/JsonSchemaValidator.java (97%) rename mcp/src/test/java/io/modelcontextprotocol/{server => spec}/DefaultJsonSchemaValidatorTests.java (98%) diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index 06223fec2..3ea8c676e 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -21,6 +21,7 @@ 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; diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java index 8056b0b75..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; diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidator.java b/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidator.java similarity index 99% rename from mcp/src/main/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidator.java rename to mcp/src/main/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidator.java index 8a5b366ff..cd8fc9659 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidator.java +++ b/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidator.java @@ -1,7 +1,7 @@ /* * Copyright 2024-2024 the original author or authors. */ -package io.modelcontextprotocol.server; +package io.modelcontextprotocol.spec; import java.util.Map; import java.util.Set; diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/JsonSchemaValidator.java b/mcp/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java similarity index 97% rename from mcp/src/main/java/io/modelcontextprotocol/server/JsonSchemaValidator.java rename to mcp/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java index 6f1857308..c95e627a9 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/JsonSchemaValidator.java +++ b/mcp/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java @@ -1,7 +1,7 @@ /* * Copyright 2024-2024 the original author or authors. */ -package io.modelcontextprotocol.server; +package io.modelcontextprotocol.spec; import java.util.Map; diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidatorTests.java b/mcp/src/test/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidatorTests.java similarity index 98% rename from mcp/src/test/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidatorTests.java rename to mcp/src/test/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidatorTests.java index a4cd30da9..9da31b38b 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/server/DefaultJsonSchemaValidatorTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidatorTests.java @@ -1,7 +1,7 @@ /* * Copyright 2024-2024 the original author or authors. */ -package io.modelcontextprotocol.server; +package io.modelcontextprotocol.spec; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -26,7 +26,8 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import io.modelcontextprotocol.server.JsonSchemaValidator.ValidationResponse; +import io.modelcontextprotocol.spec.DefaultJsonSchemaValidator; +import io.modelcontextprotocol.spec.JsonSchemaValidator.ValidationResponse; /** * Tests for {@link DefaultJsonSchemaValidator}.