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
14 changed files with 2291 additions and 140 deletions

View file

@ -127,6 +127,13 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.javacrumbs.json-unit</groupId>
<artifactId>json-unit-assertj</artifactId>
<version>${json-unit-assertj.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

View file

@ -3,6 +3,8 @@
*/
package io.modelcontextprotocol;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertWith;
@ -60,6 +62,7 @@ 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 reactor.core.publisher.Mono;
import reactor.netty.DisposableServer;
import reactor.netty.http.server.HttpServer;
@ -1122,4 +1125,261 @@ class WebFluxSseIntegrationTests {
mcpServer.closeGracefully().block();
}
// ---------------------------------------
// 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<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"}"""));
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<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();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testStructuredOutputMissingStructuredContent(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
// 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();
}
@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<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.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;
};
}
}

View file

@ -128,7 +128,14 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.javacrumbs.json-unit</groupId>
<artifactId>json-unit-assertj</artifactId>
<version>${json-unit-assertj.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
</project>

View file

@ -48,6 +48,22 @@ import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import reactor.test.StepVerifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerResponse;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.mock;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
import net.javacrumbs.jsonunit.core.Option;
class WebMvcSseIntegrationTests {
private static final int PORT = TestUtil.findAvailablePort();
@ -914,9 +930,6 @@ class WebMvcSseIntegrationTests {
mcpServer.close();
}
// ---------------------------------------
// Ping Tests
// ---------------------------------------
@Test
void testPingSuccess() {
// Create server with a tool that uses ping functionality
@ -968,4 +981,254 @@ class WebMvcSseIntegrationTests {
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();
// 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<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.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;
};
}
}

View file

@ -83,6 +83,22 @@
<artifactId>reactor-core</artifactId>
</dependency>
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>${json-schema-validator.version}</version>
</dependency>
<!-- Used by the HttpServletSseServerTransport -->
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>${jakarta.servlet.version}</version>
<scope>provided</scope>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
@ -179,15 +195,6 @@
<scope>test</scope>
</dependency>
<!-- Used by the HttpServletSseServerTransport -->
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>${jakarta.servlet.version}</version>
<scope>provided</scope>
</dependency>
<!-- Tomcat dependencies for testing -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>

View file

@ -15,8 +15,13 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.BiFunction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.spec.JsonSchemaValidator;
import io.modelcontextprotocol.spec.McpClientSession;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
@ -28,11 +33,10 @@ import io.modelcontextprotocol.spec.McpSchema.SetLevelRequest;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpServerSession;
import io.modelcontextprotocol.spec.McpServerTransportProvider;
import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.DeafaultMcpUriTemplateManagerFactory;
import io.modelcontextprotocol.util.McpUriTemplateManagerFactory;
import io.modelcontextprotocol.util.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@ -86,6 +90,8 @@ public class McpAsyncServer {
private final ObjectMapper objectMapper;
private final JsonSchemaValidator jsonSchemaValidator;
private final McpSchema.ServerCapabilities serverCapabilities;
private final McpSchema.Implementation serverInfo;
@ -119,18 +125,19 @@ public class McpAsyncServer {
*/
McpAsyncServer(McpServerTransportProvider mcpTransportProvider, ObjectMapper objectMapper,
McpServerFeatures.Async features, Duration requestTimeout,
McpUriTemplateManagerFactory uriTemplateManagerFactory) {
McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator) {
this.mcpTransportProvider = mcpTransportProvider;
this.objectMapper = objectMapper;
this.serverInfo = features.serverInfo();
this.serverCapabilities = features.serverCapabilities();
this.instructions = features.instructions();
this.tools.addAll(features.tools());
this.tools.addAll(withStructuredOutputHandling(jsonSchemaValidator, features.tools()));
this.resources.putAll(features.resources());
this.resourceTemplates.addAll(features.resourceTemplates());
this.prompts.putAll(features.prompts());
this.completions.putAll(features.completions());
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
this.jsonSchemaValidator = jsonSchemaValidator;
Map<String, McpServerSession.RequestHandler<?>> requestHandlers = new HashMap<>();
@ -286,15 +293,17 @@ public class McpAsyncServer {
return Mono.error(new McpError("Server must be configured with tool capabilities"));
}
var wrappedToolSpecification = withStructuredOutputHandling(this.jsonSchemaValidator, toolSpecification);
return Mono.defer(() -> {
// Check for duplicate tool names
if (this.tools.stream().anyMatch(th -> th.tool().name().equals(toolSpecification.tool().name()))) {
return Mono
.error(new McpError("Tool with name '" + toolSpecification.tool().name() + "' already exists"));
if (this.tools.stream().anyMatch(th -> th.tool().name().equals(wrappedToolSpecification.tool().name()))) {
return Mono.error(
new McpError("Tool with name '" + wrappedToolSpecification.tool().name() + "' already exists"));
}
this.tools.add(toolSpecification);
logger.debug("Added tool handler: {}", toolSpecification.tool().name());
this.tools.add(wrappedToolSpecification);
logger.debug("Added tool handler: {}", wrappedToolSpecification.tool().name());
if (this.serverCapabilities.tools().listChanged()) {
return notifyToolsListChanged();
@ -303,6 +312,107 @@ public class McpAsyncServer {
});
}
private static class StructuredOutputCallToolHandler
implements BiFunction<McpAsyncServerExchange, Map<String, Object>, Mono<McpSchema.CallToolResult>> {
private final BiFunction<McpAsyncServerExchange, Map<String, Object>, Mono<McpSchema.CallToolResult>> delegateCallToolResult;
private final JsonSchemaValidator jsonSchemaValidator;
private final Map<String, Object> outputSchema;
public StructuredOutputCallToolHandler(JsonSchemaValidator jsonSchemaValidator,
Map<String, Object> outputSchema,
BiFunction<McpAsyncServerExchange, Map<String, Object>, Mono<McpSchema.CallToolResult>> 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<CallToolResult> apply(McpAsyncServerExchange exchange, Map<String, Object> arguments) {
return this.delegateCallToolResult.apply(exchange, arguments).map(result -> {
if (outputSchema == null) {
if (result.structuredContent() != null) {
logger.warn(
"Tool call with no outputSchema is not expected to have a result with structured content, but got: {}",
result.structuredContent());
}
// Pass through. No validation is required if no output schema is
// provided.
return result;
}
// If an output schema is provided, servers MUST provide structured
// results that conform to this schema.
// https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema
if (result.structuredContent() == null) {
logger.warn(
"Response missing structured content which is expected when calling tool with non-empty outputSchema");
return new CallToolResult(
"Response missing structured content which is expected when calling tool with non-empty outputSchema",
true);
}
// Validate the result against the output schema
var validation = this.jsonSchemaValidator.validate(outputSchema, result.structuredContent());
if (!validation.valid()) {
logger.warn("Tool call result validation failed: {}", validation.errorMessage());
return new CallToolResult(validation.errorMessage(), true);
}
if (Utils.isEmpty(result.content())) {
// For backwards compatibility, a tool that returns structured
// content SHOULD also return functionally equivalent unstructured
// content. (For example, serialized JSON can be returned in a
// TextContent block.)
// https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content
return new CallToolResult(List.of(new McpSchema.TextContent(validation.jsonStructuredOutput())),
result.isError(), result.structuredContent());
}
return result;
});
}
}
private static List<McpServerFeatures.AsyncToolSpecification> withStructuredOutputHandling(
JsonSchemaValidator jsonSchemaValidator, List<McpServerFeatures.AsyncToolSpecification> tools) {
if (Utils.isEmpty(tools)) {
return tools;
}
return tools.stream().map(tool -> withStructuredOutputHandling(jsonSchemaValidator, tool)).toList();
}
private static McpServerFeatures.AsyncToolSpecification withStructuredOutputHandling(
JsonSchemaValidator jsonSchemaValidator, McpServerFeatures.AsyncToolSpecification toolSpecification) {
if (toolSpecification.call() instanceof StructuredOutputCallToolHandler) {
// If the tool is already wrapped, return it as is
return toolSpecification;
}
if (toolSpecification.tool().outputSchema() == null) {
// If the tool does not have an output schema, return it as is
return toolSpecification;
}
return new McpServerFeatures.AsyncToolSpecification(toolSpecification.tool(),
new StructuredOutputCallToolHandler(jsonSchemaValidator, toolSpecification.tool().outputSchema(),
toolSpecification.call()));
}
/**
* Remove a tool handler at runtime.
* @param toolName The name of the tool handler to remove

View file

@ -14,6 +14,9 @@ import java.util.function.BiConsumer;
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;
@ -170,6 +173,8 @@ public interface McpServer {
private McpSchema.ServerCapabilities serverCapabilities;
private JsonSchemaValidator jsonSchemaValidator;
private String instructions;
/**
@ -670,6 +675,20 @@ public interface McpServer {
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
@ -680,8 +699,10 @@ public interface McpServer {
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);
}
}
@ -726,6 +747,8 @@ public interface McpServer {
private final List<ResourceTemplate> 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
@ -1206,6 +1229,12 @@ public interface McpServer {
return this;
}
public SyncSpecification jsonSchemaValidator(JsonSchemaValidator jsonSchemaValidator) {
Assert.notNull(jsonSchemaValidator, "JsonSchemaValidator must not be null");
this.jsonSchemaValidator = jsonSchemaValidator;
return this;
}
/**
* Enable on "immediate execution" of the operations on the underlying
* {@link McpAsyncServer}. Defaults to false, which does blocking code offloading
@ -1234,8 +1263,11 @@ public interface McpServer {
McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures,
this.immediateExecution);
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, this.immediateExecution);
}

View file

@ -0,0 +1,169 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package io.modelcontextprotocol.spec;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import io.modelcontextprotocol.util.Assert;
/**
* Default implementation of the {@link JsonSchemaValidator} interface. This class
* provides methods to validate structured content against a JSON schema. It uses the
* NetworkNT JSON Schema Validator library for validation.
*
* @author Christian Tzolov
*/
public class DefaultJsonSchemaValidator implements JsonSchemaValidator {
private static final Logger logger = LoggerFactory.getLogger(DefaultJsonSchemaValidator.class);
private final ObjectMapper objectMapper;
private final JsonSchemaFactory schemaFactory;
// TODO: Implement a strategy to purge the cache (TTL, size limit, etc.)
private final ConcurrentHashMap<String, JsonSchema> schemaCache;
public DefaultJsonSchemaValidator() {
this(new ObjectMapper());
}
public DefaultJsonSchemaValidator(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
this.schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012);
this.schemaCache = new ConcurrentHashMap<>();
}
@Override
public ValidationResponse validate(Map<String, Object> schema, Map<String, Object> structuredContent) {
Assert.notNull(schema, "Schema must not be null");
Assert.notNull(structuredContent, "Structured content must not be null");
try {
JsonNode jsonStructuredOutput = this.objectMapper.valueToTree(structuredContent);
Set<ValidationMessage> validationResult = this.getOrCreateJsonSchema(schema).validate(jsonStructuredOutput);
// Check if validation passed
if (!validationResult.isEmpty()) {
return ValidationResponse
.asInvalid("Validation failed: structuredContent does not match tool outputSchema. "
+ "Validation errors: " + validationResult);
}
return ValidationResponse.asValid(jsonStructuredOutput.toString());
}
catch (JsonProcessingException e) {
logger.error("Failed to validate CallToolResult: Error parsing schema: {}", e);
return ValidationResponse.asInvalid("Error parsing tool JSON Schema: " + e.getMessage());
}
catch (Exception e) {
logger.error("Failed to validate CallToolResult: Unexpected error: {}", e);
return ValidationResponse.asInvalid("Unexpected validation error: " + e.getMessage());
}
}
/**
* Gets a cached JsonSchema or creates and caches a new one.
* @param schema the schema map to convert
* @return the compiled JsonSchema
* @throws JsonProcessingException if schema processing fails
*/
private JsonSchema getOrCreateJsonSchema(Map<String, Object> 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<String, Object> 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<String, Object> 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();
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package io.modelcontextprotocol.spec;
import java.util.Map;
/**
* Interface for validating structured content against a JSON schema. This interface
* defines a method to validate structured content based on the provided output schema.
*
* @author Christian Tzolov
*/
public interface JsonSchemaValidator {
/**
* Represents the result of a validation operation.
*
* @param valid Indicates whether the validation was successful.
* @param errorMessage An error message if the validation failed, otherwise null.
* @param jsonStructuredOutput The text structured content in JSON format if the
* validation was successful, otherwise null.
*/
public record ValidationResponse(boolean valid, String errorMessage, String jsonStructuredOutput) {
public static ValidationResponse asValid(String jsonStructuredOutput) {
return new ValidationResponse(true, null, jsonStructuredOutput);
}
public static ValidationResponse asInvalid(String message) {
return new ValidationResponse(false, message, null);
}
}
/**
* Validates the structured content against the provided JSON schema.
* @param schema The JSON schema to validate against.
* @param structuredContent The structured content to validate.
* @return A ValidationResponse indicating whether the validation was successful or
* not.
*/
ValidationResponse validate(Map<String, Object> schema, Map<String, Object> structuredContent);
}

View file

@ -20,6 +20,9 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.util.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -934,16 +937,26 @@ public final class McpSchema {
@JsonProperty("definitions") Map<String, Object> 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) {
} // @formatter:on
@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
/**
* Represents a tool that the server provides. Tools enable servers to expose
@ -957,17 +970,19 @@ public final class McpSchema {
* 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("title") String title,
@JsonProperty("description") String description,
@JsonProperty("inputSchema") JsonSchema inputSchema,
@JsonProperty("annotations") ToolAnnotations annotations) implements BaseMetadata { // @formatter:on
@JsonProperty("name") String name,
@JsonProperty("title") String title,
@JsonProperty("description") String description,
@JsonProperty("inputSchema") JsonSchema inputSchema,
@JsonProperty("outputSchema") Map<String, Object> outputSchema,
@JsonProperty("annotations") ToolAnnotations annotations) {// @formatter:on
/**
* @deprecated Only exists for backwards-compatibility purposes. Use
@ -975,15 +990,35 @@ public final class McpSchema {
*/
@Deprecated
public Tool(String name, String description, JsonSchema inputSchema, ToolAnnotations annotations) {
this(name, null, description, inputSchema, annotations);
this(name, null, description, inputSchema, null, annotations);
}
public Tool(String name, String description, String schema) {
this(name, null, description, parseSchema(schema), null);
/**
* @deprecated Only exists for backwards-compatibility purposes. Use
* {@link Tool#builder()} instead.
*/
@Deprecated
public Tool(String name, String description, String inputSchema) {
this(name, null, description, parseSchema(inputSchema), null, null);
}
/**
* @deprecated Only exists for backwards-compatibility purposes. Use
* {@link Tool#builder()} instead.
*/
@Deprecated
public Tool(String name, String description, String schema, ToolAnnotations annotations) {
this(name, null, description, parseSchema(schema), annotations);
this(name, null, description, parseSchema(schema), null, annotations);
}
/**
* @deprecated Only exists for backwards-compatibility purposes. Use
* {@link Tool#builder()} instead.
*/
@Deprecated
public Tool(String name, String description, String inputSchema, String outputSchema,
ToolAnnotations annotations) {
this(name, null, description, parseSchema(inputSchema), schemaToMap(outputSchema), annotations);
}
public static Builder builder() {
@ -1000,6 +1035,8 @@ public final class McpSchema {
private JsonSchema inputSchema;
private Map<String, Object> outputSchema;
private ToolAnnotations annotations;
public Builder name(String name) {
@ -1022,6 +1059,21 @@ public final class McpSchema {
return this;
}
public Builder inputSchema(String inputSchema) {
this.inputSchema = parseSchema(inputSchema);
return this;
}
public Builder outputSchema(Map<String, Object> outputSchema) {
this.outputSchema = outputSchema;
return this;
}
public Builder outputSchema(String outputSchema) {
this.outputSchema = schemaToMap(outputSchema);
return this;
}
public Builder annotations(ToolAnnotations annotations) {
this.annotations = annotations;
return this;
@ -1029,12 +1081,19 @@ public final class McpSchema {
public Tool build() {
Assert.hasText(name, "name must not be empty");
return new Tool(name, title, description, inputSchema, annotations);
return new Tool(name, title, description, inputSchema, outputSchema, annotations);
}
}
}
private static Map<String, Object> 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) {
@ -1059,7 +1118,7 @@ public final class McpSchema {
public record CallToolRequest(// @formatter:off
@JsonProperty("name") String name,
@JsonProperty("arguments") Map<String, Object> arguments,
@JsonProperty("_meta") Map<String, Object> meta) implements Request {
@JsonProperty("_meta") Map<String, Object> meta) implements Request {// @formatter:off
public CallToolRequest(String name, String jsonArguments) {
this(name, parseJsonArguments(jsonArguments), null);
@ -1119,7 +1178,7 @@ public final class McpSchema {
return new CallToolRequest(name, arguments, meta);
}
}
}// @formatter:off
}
/**
* The server's response to a tools/call request from the client.
@ -1128,110 +1187,137 @@ public final class McpSchema {
* 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> content,
@JsonProperty("isError") Boolean isError) {
@JsonProperty("isError") Boolean isError,
@JsonProperty("structuredContent") Map<String, Object> structuredContent) {// @formatter:on
/**
* Creates a new instance of {@link CallToolResult} with a string containing the
* tool result.
*
* @param content The content of the tool result. This will be mapped to a one-sized list
* with a {@link TextContent} element.
* @param isError If true, indicates that the tool execution failed and the content contains error information.
* If false or absent, indicates successful execution.
*/
public CallToolResult(String content, Boolean isError) {
this(List.of(new TextContent(content)), isError);
// backwards compatibility constructor
public CallToolResult(List<Content> content, Boolean isError) {
this(content, isError, null);
}
/**
* Creates a new instance of {@link CallToolResult} with a string containing the
* tool result.
* @param content The content of the tool result. This will be mapped to a
* one-sized list with a {@link TextContent} element.
* @param isError If true, indicates that the tool execution failed and the
* content contains error information. If false or absent, indicates successful
* execution.
*/
public CallToolResult(String content, Boolean isError) {
this(List.of(new TextContent(content)), isError);
}
/**
* Creates a builder for {@link CallToolResult}.
* @return a new builder instance
*/
public static Builder builder() {
return new Builder();
}
/**
* Builder for {@link CallToolResult}.
*/
public static class Builder {
private List<Content> content = new ArrayList<>();
private Boolean isError = false;
private Map<String, Object> structuredContent;
/**
* Sets the content list for the tool result.
* @param content the content list
* @return this builder
*/
public Builder content(List<Content> content) {
Assert.notNull(content, "content must not be null");
this.content = content;
return this;
}
public Builder structuredContent(Map<String, Object> 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);
}
/**
* Creates a builder for {@link CallToolResult}.
* @return a new builder instance
*/
public static Builder builder() {
return new Builder();
catch (IOException e) {
throw new IllegalArgumentException("Invalid structured content: " + structuredContent, e);
}
return this;
}
/**
* Builder for {@link CallToolResult}.
*/
public static class Builder {
private List<Content> content = new ArrayList<>();
private Boolean isError;
/**
* Sets the text content for the tool result.
* @param textContent the text content
* @return this builder
*/
public Builder textContent(List<String> textContent) {
Assert.notNull(textContent, "textContent must not be null");
textContent.stream().map(TextContent::new).forEach(this.content::add);
return this;
}
/**
* Sets the content list for the tool result.
* @param content the content list
* @return this builder
*/
public Builder content(List<Content> content) {
Assert.notNull(content, "content must not be null");
this.content = content;
return this;
}
/**
* Sets the text content for the tool result.
* @param textContent the text content
* @return this builder
*/
public Builder textContent(List<String> textContent) {
Assert.notNull(textContent, "textContent must not be null");
textContent.stream()
.map(TextContent::new)
.forEach(this.content::add);
return this;
}
/**
* Adds a content item to the tool result.
* @param contentItem the content item to add
* @return this builder
*/
public Builder addContent(Content contentItem) {
Assert.notNull(contentItem, "contentItem must not be null");
if (this.content == null) {
this.content = new ArrayList<>();
}
this.content.add(contentItem);
return this;
}
/**
* Adds a text content item to the tool result.
* @param text the text content
* @return this builder
*/
public Builder addTextContent(String text) {
Assert.notNull(text, "text must not be null");
return addContent(new TextContent(text));
}
/**
* Sets whether the tool execution resulted in an error.
* @param isError true if the tool execution failed, false otherwise
* @return this builder
*/
public Builder isError(Boolean isError) {
Assert.notNull(isError, "isError must not be null");
this.isError = isError;
return this;
}
/**
* Builds a new {@link CallToolResult} instance.
* @return a new CallToolResult instance
*/
public CallToolResult build() {
return new CallToolResult(content, isError);
}
/**
* Adds a content item to the tool result.
* @param contentItem the content item to add
* @return this builder
*/
public Builder addContent(Content contentItem) {
Assert.notNull(contentItem, "contentItem must not be null");
if (this.content == null) {
this.content = new ArrayList<>();
}
this.content.add(contentItem);
return this;
}
} // @formatter:on
/**
* Adds a text content item to the tool result.
* @param text the text content
* @return this builder
*/
public Builder addTextContent(String text) {
Assert.notNull(text, "text must not be null");
return addContent(new TextContent(text));
}
/**
* Sets whether the tool execution resulted in an error.
* @param isError true if the tool execution failed, false otherwise
* @return this builder
*/
public Builder isError(Boolean isError) {
Assert.notNull(isError, "isError must not be null");
this.isError = isError;
return this;
}
/**
* Builds a new {@link CallToolResult} instance.
* @return a new CallToolResult instance
*/
public CallToolResult build() {
return new CallToolResult(content, isError, structuredContent);
}
}
}
// ---------------------------
// Sampling Interfaces
@ -1286,7 +1372,7 @@ public final class McpSchema {
return new ModelPreferences(hints, costPriority, speedPriority, intelligencePriority);
}
}
} // @formatter:on
} // @formatter:on
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonIgnoreProperties(ignoreUnknown = true)
@ -1482,7 +1568,7 @@ public final class McpSchema {
/**
* 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)

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

View file

@ -96,6 +96,7 @@
<awaitility.version>4.2.0</awaitility.version>
<bnd-maven-plugin.version>7.1.0</bnd-maven-plugin.version>
<json-unit-assertj.version>4.1.0</json-unit-assertj.version>
<json-schema-validator.version>1.5.7</json-schema-validator.version>
</properties>