|
3 | 3 | */
|
4 | 4 | package io.modelcontextprotocol;
|
5 | 5 |
|
| 6 | +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; |
| 7 | +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; |
6 | 8 | import static org.assertj.core.api.Assertions.assertThat;
|
7 | 9 | import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
8 | 10 | import static org.assertj.core.api.Assertions.assertWith;
|
|
60 | 62 | import io.modelcontextprotocol.spec.McpSchema.Root;
|
61 | 63 | import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
|
62 | 64 | import io.modelcontextprotocol.spec.McpSchema.Tool;
|
| 65 | +import net.javacrumbs.jsonunit.core.Option; |
63 | 66 | import reactor.core.publisher.Mono;
|
64 | 67 | import reactor.netty.DisposableServer;
|
65 | 68 | import reactor.netty.http.server.HttpServer;
|
@@ -1122,4 +1125,261 @@ void testPingSuccess(String clientType) {
|
1122 | 1125 | mcpServer.closeGracefully().block();
|
1123 | 1126 | }
|
1124 | 1127 |
|
| 1128 | + // --------------------------------------- |
| 1129 | + // Tool Structured Output Schema Tests |
| 1130 | + // --------------------------------------- |
| 1131 | + @ParameterizedTest(name = "{0} : {displayName} ") |
| 1132 | + @ValueSource(strings = { "httpclient", "webflux" }) |
| 1133 | + void testStructuredOutputValidationSuccess(String clientType) { |
| 1134 | + var clientBuilder = clientBuilders.get(clientType); |
| 1135 | + |
| 1136 | + // Create a tool with output schema |
| 1137 | + Map<String, Object> outputSchema = Map.of( |
| 1138 | + "type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation", |
| 1139 | + Map.of("type", "string"), "timestamp", Map.of("type", "string")), |
| 1140 | + "required", List.of("result", "operation")); |
| 1141 | + |
| 1142 | + Tool calculatorTool = Tool.builder() |
| 1143 | + .name("calculator") |
| 1144 | + .description("Performs mathematical calculations") |
| 1145 | + .outputSchema(outputSchema) |
| 1146 | + .build(); |
| 1147 | + |
| 1148 | + McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool, |
| 1149 | + (exchange, request) -> { |
| 1150 | + String expression = (String) request.getOrDefault("expression", "2 + 3"); |
| 1151 | + double result = evaluateExpression(expression); |
| 1152 | + return CallToolResult.builder() |
| 1153 | + .structuredContent( |
| 1154 | + Map.of("result", result, "operation", expression, "timestamp", "2024-01-01T10:00:00Z")) |
| 1155 | + .build(); |
| 1156 | + }); |
| 1157 | + |
| 1158 | + var mcpServer = McpServer.sync(mcpServerTransportProvider) |
| 1159 | + .serverInfo("test-server", "1.0.0") |
| 1160 | + .capabilities(ServerCapabilities.builder().tools(true).build()) |
| 1161 | + .tools(tool) |
| 1162 | + .build(); |
| 1163 | + |
| 1164 | + try (var mcpClient = clientBuilder.build()) { |
| 1165 | + InitializeResult initResult = mcpClient.initialize(); |
| 1166 | + assertThat(initResult).isNotNull(); |
| 1167 | + |
| 1168 | + // Verify tool is listed with output schema |
| 1169 | + var toolsList = mcpClient.listTools(); |
| 1170 | + assertThat(toolsList.tools()).hasSize(1); |
| 1171 | + assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator"); |
| 1172 | + // Note: outputSchema might be null in sync server, but validation still works |
| 1173 | + |
| 1174 | + // Call tool with valid structured output |
| 1175 | + CallToolResult response = mcpClient |
| 1176 | + .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3"))); |
| 1177 | + |
| 1178 | + assertThat(response).isNotNull(); |
| 1179 | + assertThat(response.isError()).isFalse(); |
| 1180 | + assertThat(response.content()).hasSize(1); |
| 1181 | + assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class); |
| 1182 | + |
| 1183 | + assertThatJson(((McpSchema.TextContent) response.content().get(0)).text()).when(Option.IGNORING_ARRAY_ORDER) |
| 1184 | + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) |
| 1185 | + .isObject() |
| 1186 | + .isEqualTo(json(""" |
| 1187 | + {"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}""")); |
| 1188 | + |
| 1189 | + assertThat(response.structuredContent()).isNotNull(); |
| 1190 | + assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER) |
| 1191 | + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) |
| 1192 | + .isObject() |
| 1193 | + .isEqualTo(json(""" |
| 1194 | + {"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}""")); |
| 1195 | + } |
| 1196 | + |
| 1197 | + mcpServer.close(); |
| 1198 | + } |
| 1199 | + |
| 1200 | + @ParameterizedTest(name = "{0} : {displayName} ") |
| 1201 | + @ValueSource(strings = { "httpclient", "webflux" }) |
| 1202 | + void testStructuredOutputValidationFailure(String clientType) { |
| 1203 | + var clientBuilder = clientBuilders.get(clientType); |
| 1204 | + |
| 1205 | + // Create a tool with output schema |
| 1206 | + Map<String, Object> outputSchema = Map.of("type", "object", "properties", |
| 1207 | + Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required", |
| 1208 | + List.of("result", "operation")); |
| 1209 | + |
| 1210 | + Tool calculatorTool = Tool.builder() |
| 1211 | + .name("calculator") |
| 1212 | + .description("Performs mathematical calculations") |
| 1213 | + .outputSchema(outputSchema) |
| 1214 | + .build(); |
| 1215 | + |
| 1216 | + McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool, |
| 1217 | + (exchange, request) -> { |
| 1218 | + // Return invalid structured output. Result should be number, missing |
| 1219 | + // operation |
| 1220 | + return CallToolResult.builder() |
| 1221 | + .addTextContent("Invalid calculation") |
| 1222 | + .structuredContent(Map.of("result", "not-a-number", "extra", "field")) |
| 1223 | + .build(); |
| 1224 | + }); |
| 1225 | + |
| 1226 | + var mcpServer = McpServer.sync(mcpServerTransportProvider) |
| 1227 | + .serverInfo("test-server", "1.0.0") |
| 1228 | + .capabilities(ServerCapabilities.builder().tools(true).build()) |
| 1229 | + .tools(tool) |
| 1230 | + .build(); |
| 1231 | + |
| 1232 | + try (var mcpClient = clientBuilder.build()) { |
| 1233 | + InitializeResult initResult = mcpClient.initialize(); |
| 1234 | + assertThat(initResult).isNotNull(); |
| 1235 | + |
| 1236 | + // Call tool with invalid structured output |
| 1237 | + CallToolResult response = mcpClient |
| 1238 | + .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3"))); |
| 1239 | + |
| 1240 | + assertThat(response).isNotNull(); |
| 1241 | + assertThat(response.isError()).isTrue(); |
| 1242 | + assertThat(response.content()).hasSize(1); |
| 1243 | + assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class); |
| 1244 | + |
| 1245 | + String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text(); |
| 1246 | + assertThat(errorMessage).contains("Validation failed"); |
| 1247 | + } |
| 1248 | + |
| 1249 | + mcpServer.close(); |
| 1250 | + } |
| 1251 | + |
| 1252 | + @ParameterizedTest(name = "{0} : {displayName} ") |
| 1253 | + @ValueSource(strings = { "httpclient", "webflux" }) |
| 1254 | + void testStructuredOutputMissingStructuredContent(String clientType) { |
| 1255 | + var clientBuilder = clientBuilders.get(clientType); |
| 1256 | + |
| 1257 | + // Create a tool with output schema |
| 1258 | + Map<String, Object> outputSchema = Map.of("type", "object", "properties", |
| 1259 | + Map.of("result", Map.of("type", "number")), "required", List.of("result")); |
| 1260 | + |
| 1261 | + Tool calculatorTool = Tool.builder() |
| 1262 | + .name("calculator") |
| 1263 | + .description("Performs mathematical calculations") |
| 1264 | + .outputSchema(outputSchema) |
| 1265 | + .build(); |
| 1266 | + |
| 1267 | + McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool, |
| 1268 | + (exchange, request) -> { |
| 1269 | + // Return result without structured content but tool has output schema |
| 1270 | + return CallToolResult.builder().addTextContent("Calculation completed").build(); |
| 1271 | + }); |
| 1272 | + |
| 1273 | + var mcpServer = McpServer.sync(mcpServerTransportProvider) |
| 1274 | + .serverInfo("test-server", "1.0.0") |
| 1275 | + .capabilities(ServerCapabilities.builder().tools(true).build()) |
| 1276 | + .tools(tool) |
| 1277 | + .build(); |
| 1278 | + |
| 1279 | + try (var mcpClient = clientBuilder.build()) { |
| 1280 | + InitializeResult initResult = mcpClient.initialize(); |
| 1281 | + assertThat(initResult).isNotNull(); |
| 1282 | + |
| 1283 | + // Call tool that should return structured content but doesn't |
| 1284 | + CallToolResult response = mcpClient |
| 1285 | + .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3"))); |
| 1286 | + |
| 1287 | + assertThat(response).isNotNull(); |
| 1288 | + assertThat(response.isError()).isTrue(); |
| 1289 | + assertThat(response.content()).hasSize(1); |
| 1290 | + assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class); |
| 1291 | + |
| 1292 | + String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text(); |
| 1293 | + assertThat(errorMessage).isEqualTo( |
| 1294 | + "Response missing structured content which is expected when calling tool with non-empty outputSchema"); |
| 1295 | + } |
| 1296 | + |
| 1297 | + mcpServer.close(); |
| 1298 | + } |
| 1299 | + |
| 1300 | + @ParameterizedTest(name = "{0} : {displayName} ") |
| 1301 | + @ValueSource(strings = { "httpclient", "webflux" }) |
| 1302 | + void testStructuredOutputRuntimeToolAddition(String clientType) { |
| 1303 | + var clientBuilder = clientBuilders.get(clientType); |
| 1304 | + |
| 1305 | + // Start server without tools |
| 1306 | + var mcpServer = McpServer.sync(mcpServerTransportProvider) |
| 1307 | + .serverInfo("test-server", "1.0.0") |
| 1308 | + .capabilities(ServerCapabilities.builder().tools(true).build()) |
| 1309 | + .build(); |
| 1310 | + |
| 1311 | + try (var mcpClient = clientBuilder.build()) { |
| 1312 | + InitializeResult initResult = mcpClient.initialize(); |
| 1313 | + assertThat(initResult).isNotNull(); |
| 1314 | + |
| 1315 | + // Initially no tools |
| 1316 | + assertThat(mcpClient.listTools().tools()).isEmpty(); |
| 1317 | + |
| 1318 | + // Add tool with output schema at runtime |
| 1319 | + Map<String, Object> outputSchema = Map.of("type", "object", "properties", |
| 1320 | + Map.of("message", Map.of("type", "string"), "count", Map.of("type", "integer")), "required", |
| 1321 | + List.of("message", "count")); |
| 1322 | + |
| 1323 | + Tool dynamicTool = Tool.builder() |
| 1324 | + .name("dynamic-tool") |
| 1325 | + .description("Dynamically added tool") |
| 1326 | + .outputSchema(outputSchema) |
| 1327 | + .build(); |
| 1328 | + |
| 1329 | + McpServerFeatures.SyncToolSpecification toolSpec = new McpServerFeatures.SyncToolSpecification(dynamicTool, |
| 1330 | + (exchange, request) -> { |
| 1331 | + int count = (Integer) request.getOrDefault("count", 1); |
| 1332 | + return CallToolResult.builder() |
| 1333 | + .addTextContent("Dynamic tool executed " + count + " times") |
| 1334 | + .structuredContent(Map.of("message", "Dynamic execution", "count", count)) |
| 1335 | + .build(); |
| 1336 | + }); |
| 1337 | + |
| 1338 | + // Add tool to server |
| 1339 | + mcpServer.addTool(toolSpec); |
| 1340 | + |
| 1341 | + // Wait for tool list change notification |
| 1342 | + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { |
| 1343 | + assertThat(mcpClient.listTools().tools()).hasSize(1); |
| 1344 | + }); |
| 1345 | + |
| 1346 | + // Verify tool was added with output schema |
| 1347 | + var toolsList = mcpClient.listTools(); |
| 1348 | + assertThat(toolsList.tools()).hasSize(1); |
| 1349 | + assertThat(toolsList.tools().get(0).name()).isEqualTo("dynamic-tool"); |
| 1350 | + // Note: outputSchema might be null in sync server, but validation still works |
| 1351 | + |
| 1352 | + // Call dynamically added tool |
| 1353 | + CallToolResult response = mcpClient |
| 1354 | + .callTool(new McpSchema.CallToolRequest("dynamic-tool", Map.of("count", 3))); |
| 1355 | + |
| 1356 | + assertThat(response).isNotNull(); |
| 1357 | + assertThat(response.isError()).isFalse(); |
| 1358 | + assertThat(response.content()).hasSize(1); |
| 1359 | + assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class); |
| 1360 | + assertThat(((McpSchema.TextContent) response.content().get(0)).text()) |
| 1361 | + .isEqualTo("Dynamic tool executed 3 times"); |
| 1362 | + |
| 1363 | + assertThat(response.structuredContent()).isNotNull(); |
| 1364 | + assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER) |
| 1365 | + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) |
| 1366 | + .isObject() |
| 1367 | + .isEqualTo(json(""" |
| 1368 | + {"count":3,"message":"Dynamic execution"}""")); |
| 1369 | + } |
| 1370 | + |
| 1371 | + mcpServer.close(); |
| 1372 | + } |
| 1373 | + |
| 1374 | + private double evaluateExpression(String expression) { |
| 1375 | + // Simple expression evaluator for testing |
| 1376 | + return switch (expression) { |
| 1377 | + case "2 + 3" -> 5.0; |
| 1378 | + case "10 * 2" -> 20.0; |
| 1379 | + case "7 + 8" -> 15.0; |
| 1380 | + case "5 + 3" -> 8.0; |
| 1381 | + default -> 0.0; |
| 1382 | + }; |
| 1383 | + } |
| 1384 | + |
1125 | 1385 | }
|
0 commit comments