Skip to content

Commit 7ce1552

Browse files
committed
feat: add structured output support for MCP tools
- Add JsonSchemaValidator interface and DefaultJsonSchemaValidator implementation - Extend Tool schema to support outputSchema field for defining expected output structure - Add structuredContent field to CallToolResult for validated structured responses - Implement automatic validation of tool outputs against their defined schemas - Add comprehensive test coverage for structured output validation scenarios - Add json-schema-validator and json-unit-assertj dependencies for validation and testing - Update McpServer builders to accept custom JsonSchemaValidator instances - Ensure backward compatibility with existing tools without output schemas This implements the MCP specification requirement that tools with output schemas must provide structured results conforming to those schemas, with automatic validation and error handling for non-conforming outputs. https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
1 parent d9006ec commit 7ce1552

File tree

15 files changed

+2048
-36
lines changed

15 files changed

+2048
-36
lines changed

mcp-spring/mcp-spring-webflux/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,13 @@
127127
<scope>test</scope>
128128
</dependency>
129129

130+
<dependency>
131+
<groupId>net.javacrumbs.json-unit</groupId>
132+
<artifactId>json-unit-assertj</artifactId>
133+
<version>${json-unit-assertj.version}</version>
134+
<scope>test</scope>
135+
</dependency>
136+
130137
</dependencies>
131138

132139

mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java

Lines changed: 251 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@
4747
import static org.assertj.core.api.Assertions.assertWith;
4848
import static org.awaitility.Awaitility.await;
4949
import static org.mockito.Mockito.mock;
50+
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
51+
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
52+
53+
import net.javacrumbs.jsonunit.core.Option;
5054

5155
class WebFluxSseIntegrationTests {
5256

@@ -1023,4 +1027,250 @@ void testCompletionShouldReturnExpectedSuggestions(String clientType) {
10231027
mcpServer.close();
10241028
}
10251029

1026-
}
1030+
// ---------------------------------------
1031+
// Tool Structured Output Schema Tests
1032+
// ---------------------------------------
1033+
1034+
@ParameterizedTest(name = "{0} : {displayName} ")
1035+
@ValueSource(strings = { "httpclient", "webflux" })
1036+
void testStructuredOutputValidationSuccess(String clientType) {
1037+
var clientBuilder = clientBuilders.get(clientType);
1038+
1039+
// Create a tool with output schema
1040+
Map<String, Object> outputSchema = Map.of(
1041+
"type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
1042+
Map.of("type", "string"), "timestamp", Map.of("type", "string")),
1043+
"required", List.of("result", "operation"));
1044+
1045+
Tool calculatorTool = new Tool("calculator", "Performs mathematical calculations", (McpSchema.JsonSchema) null,
1046+
outputSchema, (McpSchema.ToolAnnotations) null);
1047+
1048+
McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
1049+
(exchange, request) -> {
1050+
String expression = (String) request.getOrDefault("expression", "2 + 3");
1051+
double result = evaluateExpression(expression);
1052+
return CallToolResult.builder()
1053+
.structuredContent(
1054+
Map.of("result", result, "operation", expression, "timestamp", "2024-01-01T10:00:00Z"))
1055+
.build();
1056+
});
1057+
1058+
var mcpServer = McpServer.sync(mcpServerTransportProvider)
1059+
.serverInfo("test-server", "1.0.0")
1060+
.capabilities(ServerCapabilities.builder().tools(true).build())
1061+
.tools(tool)
1062+
.build();
1063+
1064+
try (var mcpClient = clientBuilder.build()) {
1065+
InitializeResult initResult = mcpClient.initialize();
1066+
assertThat(initResult).isNotNull();
1067+
1068+
// Verify tool is listed with output schema
1069+
var toolsList = mcpClient.listTools();
1070+
assertThat(toolsList.tools()).hasSize(1);
1071+
assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator");
1072+
// Note: outputSchema might be null in sync server, but validation still works
1073+
1074+
// Call tool with valid structured output
1075+
CallToolResult response = mcpClient
1076+
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
1077+
1078+
assertThat(response).isNotNull();
1079+
assertThat(response.isError()).isFalse();
1080+
assertThat(response.content()).hasSize(1);
1081+
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
1082+
1083+
assertThatJson(((McpSchema.TextContent) response.content().get(0)).text()).when(Option.IGNORING_ARRAY_ORDER)
1084+
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
1085+
.isObject()
1086+
.isEqualTo(json("""
1087+
{"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}"""));
1088+
1089+
assertThat(response.structuredContent()).isNotNull();
1090+
assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
1091+
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
1092+
.isObject()
1093+
.isEqualTo(json("""
1094+
{"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}"""));
1095+
}
1096+
1097+
mcpServer.close();
1098+
}
1099+
1100+
@ParameterizedTest(name = "{0} : {displayName} ")
1101+
@ValueSource(strings = { "httpclient", "webflux" })
1102+
void testStructuredOutputValidationFailure(String clientType) {
1103+
var clientBuilder = clientBuilders.get(clientType);
1104+
1105+
// Create a tool with output schema
1106+
Map<String, Object> outputSchema = Map.of("type", "object", "properties",
1107+
Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required",
1108+
List.of("result", "operation"));
1109+
1110+
Tool calculatorTool = new Tool("calculator", "Performs mathematical calculations", (McpSchema.JsonSchema) null,
1111+
outputSchema, (McpSchema.ToolAnnotations) null);
1112+
1113+
McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
1114+
(exchange, request) -> {
1115+
// Return invalid structured output. Result should be number, missing
1116+
// operation
1117+
return CallToolResult.builder()
1118+
.addTextContent("Invalid calculation")
1119+
.structuredContent(Map.of("result", "not-a-number", "extra", "field"))
1120+
.build();
1121+
});
1122+
1123+
var mcpServer = McpServer.sync(mcpServerTransportProvider)
1124+
.serverInfo("test-server", "1.0.0")
1125+
.capabilities(ServerCapabilities.builder().tools(true).build())
1126+
.tools(tool)
1127+
.build();
1128+
1129+
try (var mcpClient = clientBuilder.build()) {
1130+
InitializeResult initResult = mcpClient.initialize();
1131+
assertThat(initResult).isNotNull();
1132+
1133+
// Call tool with invalid structured output
1134+
CallToolResult response = mcpClient
1135+
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
1136+
1137+
assertThat(response).isNotNull();
1138+
assertThat(response.isError()).isTrue();
1139+
assertThat(response.content()).hasSize(1);
1140+
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
1141+
1142+
String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
1143+
assertThat(errorMessage).contains("Validation failed");
1144+
}
1145+
1146+
mcpServer.close();
1147+
}
1148+
1149+
@ParameterizedTest(name = "{0} : {displayName} ")
1150+
@ValueSource(strings = { "httpclient", "webflux" })
1151+
void testStructuredOutputMissingStructuredContent(String clientType) {
1152+
var clientBuilder = clientBuilders.get(clientType);
1153+
1154+
// Create a tool with output schema
1155+
Map<String, Object> outputSchema = Map.of("type", "object", "properties",
1156+
Map.of("result", Map.of("type", "number")), "required", List.of("result"));
1157+
1158+
Tool calculatorTool = new Tool("calculator", "Performs mathematical calculations", (McpSchema.JsonSchema) null,
1159+
outputSchema, (McpSchema.ToolAnnotations) null);
1160+
1161+
McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
1162+
(exchange, request) -> {
1163+
// Return result without structured content but tool has output schema
1164+
return CallToolResult.builder().addTextContent("Calculation completed").build();
1165+
});
1166+
1167+
var mcpServer = McpServer.sync(mcpServerTransportProvider)
1168+
.serverInfo("test-server", "1.0.0")
1169+
.capabilities(ServerCapabilities.builder().tools(true).build())
1170+
.tools(tool)
1171+
.build();
1172+
1173+
try (var mcpClient = clientBuilder.build()) {
1174+
InitializeResult initResult = mcpClient.initialize();
1175+
assertThat(initResult).isNotNull();
1176+
1177+
// Call tool that should return structured content but doesn't
1178+
CallToolResult response = mcpClient
1179+
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
1180+
1181+
assertThat(response).isNotNull();
1182+
assertThat(response.isError()).isTrue();
1183+
assertThat(response.content()).hasSize(1);
1184+
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
1185+
1186+
String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
1187+
assertThat(errorMessage)
1188+
.isEqualTo("Tool call with non-empty outputSchema must have a result with structured content");
1189+
}
1190+
1191+
mcpServer.close();
1192+
}
1193+
1194+
@ParameterizedTest(name = "{0} : {displayName} ")
1195+
@ValueSource(strings = { "httpclient", "webflux" })
1196+
void testStructuredOutputRuntimeToolAddition(String clientType) {
1197+
var clientBuilder = clientBuilders.get(clientType);
1198+
1199+
// Start server without tools
1200+
var mcpServer = McpServer.sync(mcpServerTransportProvider)
1201+
.serverInfo("test-server", "1.0.0")
1202+
.capabilities(ServerCapabilities.builder().tools(true).build())
1203+
.build();
1204+
1205+
try (var mcpClient = clientBuilder.build()) {
1206+
InitializeResult initResult = mcpClient.initialize();
1207+
assertThat(initResult).isNotNull();
1208+
1209+
// Initially no tools
1210+
assertThat(mcpClient.listTools().tools()).isEmpty();
1211+
1212+
// Add tool with output schema at runtime
1213+
Map<String, Object> outputSchema = Map.of("type", "object", "properties",
1214+
Map.of("message", Map.of("type", "string"), "count", Map.of("type", "integer")), "required",
1215+
List.of("message", "count"));
1216+
1217+
Tool dynamicTool = new Tool("dynamic-tool", "Dynamically added tool", (McpSchema.JsonSchema) null,
1218+
outputSchema, (McpSchema.ToolAnnotations) null);
1219+
1220+
McpServerFeatures.SyncToolSpecification toolSpec = new McpServerFeatures.SyncToolSpecification(dynamicTool,
1221+
(exchange, request) -> {
1222+
int count = (Integer) request.getOrDefault("count", 1);
1223+
return CallToolResult.builder()
1224+
.addTextContent("Dynamic tool executed " + count + " times")
1225+
.structuredContent(Map.of("message", "Dynamic execution", "count", count))
1226+
.build();
1227+
});
1228+
1229+
// Add tool to server
1230+
mcpServer.addTool(toolSpec);
1231+
1232+
// Wait for tool list change notification
1233+
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
1234+
assertThat(mcpClient.listTools().tools()).hasSize(1);
1235+
});
1236+
1237+
// Verify tool was added with output schema
1238+
var toolsList = mcpClient.listTools();
1239+
assertThat(toolsList.tools()).hasSize(1);
1240+
assertThat(toolsList.tools().get(0).name()).isEqualTo("dynamic-tool");
1241+
// Note: outputSchema might be null in sync server, but validation still works
1242+
1243+
// Call dynamically added tool
1244+
CallToolResult response = mcpClient
1245+
.callTool(new McpSchema.CallToolRequest("dynamic-tool", Map.of("count", 3)));
1246+
1247+
assertThat(response).isNotNull();
1248+
assertThat(response.isError()).isFalse();
1249+
assertThat(response.content()).hasSize(1);
1250+
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
1251+
assertThat(((McpSchema.TextContent) response.content().get(0)).text())
1252+
.isEqualTo("Dynamic tool executed 3 times");
1253+
1254+
assertThat(response.structuredContent()).isNotNull();
1255+
assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
1256+
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
1257+
.isObject()
1258+
.isEqualTo(json("""
1259+
{"count":3,"message":"Dynamic execution"}"""));
1260+
}
1261+
1262+
mcpServer.close();
1263+
}
1264+
1265+
private double evaluateExpression(String expression) {
1266+
// Simple expression evaluator for testing
1267+
return switch (expression) {
1268+
case "2 + 3" -> 5.0;
1269+
case "10 * 2" -> 20.0;
1270+
case "7 + 8" -> 15.0;
1271+
case "5 + 3" -> 8.0;
1272+
default -> 0.0;
1273+
};
1274+
}
1275+
1276+
}

mcp-spring/mcp-spring-webmvc/pom.xml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,14 @@
128128
<scope>test</scope>
129129
</dependency>
130130

131+
<dependency>
132+
<groupId>net.javacrumbs.json-unit</groupId>
133+
<artifactId>json-unit-assertj</artifactId>
134+
<version>${json-unit-assertj.version}</version>
135+
<scope>test</scope>
136+
</dependency>
137+
131138
</dependencies>
132139

133140

134-
</project>
141+
</project>

0 commit comments

Comments
 (0)