feat: add structured output support for MCP tools (#357)
- Add JsonSchemaValidator interface and DefaultJsonSchemaValidator implementation - put the JsonSchemaValidator classes into a dedicated spec package - add schema caching to DefaultJsonSchemaValidator for better performance - 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 <christian.tzolov@broadcom.com>
This commit is contained in:
parent
b678de6514
commit
8a2f97f1b1
3 changed files with 2291 additions and 140 deletions
|
|
@ -32,6 +32,8 @@ import io.modelcontextprotocol.spec.McpSchema.Role;
|
|||
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.assertj.core.api.InstanceOfAssertFactories.type;
|
||||
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 {
|
||||
|
||||
|
|
@ -1070,4 +1074,242 @@ class HttpServletSseServerTransportProviderIntegrationTests {
|
|||
mcpServer.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Tool Structured Output Schema Tests
|
||||
// ---------------------------------------
|
||||
@Test
|
||||
void testStructuredOutputValidationSuccess() {
|
||||
// Create a tool with output schema
|
||||
Map<String, Object> 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 = Tool.builder()
|
||||
.name("calculator")
|
||||
.description("Performs mathematical calculations")
|
||||
.outputSchema(outputSchema)
|
||||
.build();
|
||||
|
||||
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<String, Object> 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 = Tool.builder()
|
||||
.name("calculator")
|
||||
.description("Performs mathematical calculations")
|
||||
.outputSchema(outputSchema)
|
||||
.build();
|
||||
|
||||
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<String, Object> outputSchema = Map.of("type", "object", "properties",
|
||||
Map.of("result", Map.of("type", "number")), "required", List.of("result"));
|
||||
|
||||
Tool calculatorTool = Tool.builder()
|
||||
.name("calculator")
|
||||
.description("Performs mathematical calculations")
|
||||
.outputSchema(outputSchema)
|
||||
.build();
|
||||
|
||||
McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
|
||||
(exchange, request) -> {
|
||||
// Return result without structured content but tool has output schema
|
||||
return CallToolResult.builder().addTextContent("Calculation completed").build();
|
||||
});
|
||||
|
||||
var mcpServer = McpServer.sync(mcpServerTransportProvider)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tools(tool)
|
||||
.build();
|
||||
|
||||
try (var mcpClient = clientBuilder.build()) {
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
// Call tool that should return structured content but doesn't
|
||||
CallToolResult response = mcpClient
|
||||
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.isError()).isTrue();
|
||||
assertThat(response.content()).hasSize(1);
|
||||
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
|
||||
|
||||
String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
|
||||
assertThat(errorMessage).isEqualTo(
|
||||
"Response missing structured content which is expected when calling tool with non-empty outputSchema");
|
||||
}
|
||||
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStructuredOutputRuntimeToolAddition() {
|
||||
// Start server without tools
|
||||
var mcpServer = McpServer.sync(mcpServerTransportProvider)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.build();
|
||||
|
||||
try (var mcpClient = clientBuilder.build()) {
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
// Initially no tools
|
||||
assertThat(mcpClient.listTools().tools()).isEmpty();
|
||||
|
||||
// Add tool with output schema at runtime
|
||||
Map<String, Object> 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 = Tool.builder()
|
||||
.name("dynamic-tool")
|
||||
.description("Dynamically added tool")
|
||||
.outputSchema(outputSchema)
|
||||
.build();
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,698 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
package io.modelcontextprotocol.spec;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.modelcontextprotocol.spec.DefaultJsonSchemaValidator;
|
||||
import io.modelcontextprotocol.spec.JsonSchemaValidator.ValidationResponse;
|
||||
|
||||
/**
|
||||
* Tests for {@link DefaultJsonSchemaValidator}.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
class DefaultJsonSchemaValidatorTests {
|
||||
|
||||
private DefaultJsonSchemaValidator validator;
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Mock
|
||||
private ObjectMapper mockObjectMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
validator = new DefaultJsonSchemaValidator();
|
||||
objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to convert JSON string to Map<String, Object>
|
||||
*/
|
||||
private Map<String, Object> toMap(String json) {
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
}
|
||||
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<String, Object> schema = toMap(schemaJson);
|
||||
Map<String, Object> 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<String, Object> schema = toMap(schemaJson);
|
||||
Map<String, Object> 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<String, Object> schema = toMap(schemaJson);
|
||||
Map<String, Object> 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<String, Object> schema = toMap(schemaJson);
|
||||
Map<String, Object> 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<String, Object> schema = toMap(schemaJson);
|
||||
Map<String, Object> 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<String, Object> schema = toMap(schemaJson);
|
||||
Map<String, Object> 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<String, Object> schema = toMap(schemaJson);
|
||||
Map<String, Object> 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<String, Object> schema = toMap(schemaJson);
|
||||
Map<String, Object> 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<String, Object> schema = toMap(schemaJson);
|
||||
Map<String, Object> 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<String, Object> schema = toMap(schemaJson);
|
||||
Map<String, Object> 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<String, Object> schema = toMap(schemaJson);
|
||||
Map<String, Object> 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<String, Object> schema = toMap(schemaJson);
|
||||
Map<String, Object> 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<String, Object> schema = Map.of("type", "object");
|
||||
Map<String, Object> structuredContent = Map.of("key", "value");
|
||||
|
||||
// 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("Failed to convert schema to JsonNode"));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("provideValidSchemaAndContentPairs")
|
||||
void testValidateWithVariousValidInputs(Map<String, Object> schema, Map<String, Object> 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<String, Object> schema, Map<String, Object> 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<String, Object> staticToMap(String json) {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(json, new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
}
|
||||
catch (Exception e) {
|
||||
throw new RuntimeException("Failed to parse JSON: " + json, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Stream<Arguments> 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<Arguments> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -830,9 +830,234 @@ public class McpSchemaTests {
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue