diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProvider.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProvider.java index a8d5ea451c..621e45da30 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProvider.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProvider.java @@ -17,6 +17,7 @@ package org.springframework.ai.mcp.annotation.provider.tool; import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; import java.util.Comparator; import java.util.List; import java.util.function.BiFunction; @@ -127,12 +128,16 @@ public List getToolSpecifications() { && !ReactiveUtils.isReactiveReturnTypeOfCallToolResult(mcpToolMethod)) { ReactiveUtils.getReactiveReturnTypeArgument(mcpToolMethod).ifPresent(typeArgument -> { - Class methodReturnType = typeArgument instanceof Class ? (Class) typeArgument - : null; - if (!ClassUtils.isPrimitiveOrWrapper(methodReturnType) - && !ClassUtils.isSimpleValueType(methodReturnType)) { + if (typeArgument instanceof Class methodReturnType) { + if (!ClassUtils.isPrimitiveOrWrapper(methodReturnType) + && !ClassUtils.isSimpleValueType(methodReturnType)) { + toolBuilder.outputSchema(this.getJsonMapper(), + McpJsonSchemaGenerator.generateFromClass(methodReturnType)); + } + } + else if (typeArgument instanceof ParameterizedType) { toolBuilder.outputSchema(this.getJsonMapper(), - McpJsonSchemaGenerator.generateFromClass((Class) typeArgument)); + McpJsonSchemaGenerator.generateFromType(typeArgument)); } }); } diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProvider.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProvider.java index d64aad551a..7de44508d1 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProvider.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProvider.java @@ -17,6 +17,7 @@ package org.springframework.ai.mcp.annotation.provider.tool; import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; import java.util.Comparator; import java.util.List; import java.util.function.BiFunction; @@ -132,10 +133,14 @@ public List getToolSpecifications() { && !ReactiveUtils.isReactiveReturnTypeOfCallToolResult(mcpToolMethod)) { ReactiveUtils.getReactiveReturnTypeArgument(mcpToolMethod).ifPresent(typeArgument -> { - Class methodReturnType = typeArgument instanceof Class ? (Class) typeArgument - : null; - if (!ClassUtils.isPrimitiveOrWrapper(methodReturnType) - && !ClassUtils.isSimpleValueType(methodReturnType)) { + if (typeArgument instanceof Class methodReturnType) { + if (!ClassUtils.isPrimitiveOrWrapper(methodReturnType) + && !ClassUtils.isSimpleValueType(methodReturnType)) { + toolBuilder.outputSchema(this.getJsonMapper(), + McpJsonSchemaGenerator.generateFromClass(methodReturnType)); + } + } + else if (typeArgument instanceof ParameterizedType) { toolBuilder.outputSchema(this.getJsonMapper(), McpJsonSchemaGenerator.generateFromType(typeArgument)); } diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProviderTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProviderTests.java index 81a74f2205..7dd1045a97 100644 --- a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProviderTests.java +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProviderTests.java @@ -917,6 +917,42 @@ public Mono> listResponseTool(String input) { [{"message":"Processed: test"}]""")); } + @Test + void testToolWithStructuredListReturnType() { + record CustomResult(String message) { + } + + class ListResponseTool { + + @McpTool(name = "list-response", description = "Tool List response", generateOutputSchema = true) + public Mono> listResponseTool(String input) { + return Mono.just(List.of(new CustomResult("Processed: " + input))); + } + + } + + AsyncMcpToolProvider provider = new AsyncMcpToolProvider(List.of(new ListResponseTool())); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + assertThat(toolSpec.tool().outputSchema()).containsEntry("type", "array").containsKey("items"); + assertThat(toolSpec.tool().outputSchema().toString()).contains("message"); + + Mono result = toolSpec.callHandler() + .apply(mock(McpAsyncServerExchange.class), + CallToolRequest.builder("list-response").arguments(Map.of("input", "test")).build()); + + StepVerifier.create(result).assertNext(callToolResult -> { + assertThat(callToolResult.isError()).isFalse(); + assertThat(callToolResult.structuredContent()).isInstanceOf(List.class); + assertThat((List) callToolResult.structuredContent()).singleElement() + .isInstanceOfSatisfying(Map.class, + item -> assertThat(item).containsEntry("message", "Processed: test")); + }).verifyComplete(); + } + @Test void testToolWithFluxReturnType() { diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProviderTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProviderTests.java index d09ceaef9e..0df5f17c8e 100644 --- a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProviderTests.java +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProviderTests.java @@ -952,6 +952,42 @@ public Mono noOutputSchemaTool(String input) { assertThat(toolSpec.tool().outputSchema()).isNull(); } + @Test + void testToolWithStructuredListReturnType() { + record CustomResult(String message) { + } + + class ListResponseTool { + + @McpTool(name = "list-response", description = "Tool List response", generateOutputSchema = true) + public Mono> listResponseTool(String input) { + return Mono.just(List.of(new CustomResult("Processed: " + input))); + } + + } + + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(new ListResponseTool())); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + assertThat(toolSpec.tool().outputSchema()).containsEntry("type", "array").containsKey("items"); + assertThat(toolSpec.tool().outputSchema().toString()).contains("message"); + + Mono result = toolSpec.callHandler() + .apply(mock(McpTransportContext.class), + CallToolRequest.builder("list-response").arguments(Map.of("input", "test")).build()); + + StepVerifier.create(result).assertNext(callToolResult -> { + assertThat(callToolResult.isError()).isFalse(); + assertThat(callToolResult.structuredContent()).isInstanceOf(List.class); + assertThat((List) callToolResult.structuredContent()).singleElement() + .isInstanceOfSatisfying(Map.class, + item -> assertThat(item).containsEntry("message", "Processed: test")); + }).verifyComplete(); + } + @Test void testGetToolSpecificationsWithOutputSchemaGeneration() { // Helper class for complex return type