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:
Christian Tzolov 2025-06-30 14:08:50 +02:00
commit 8a2f97f1b1
3 changed files with 2291 additions and 140 deletions

View file

@ -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;
};
}
}

View file

@ -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);
}
}

View file

@ -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