4747import static org .assertj .core .api .Assertions .assertWith ;
4848import static org .awaitility .Awaitility .await ;
4949import 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
5155class 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+ }
0 commit comments