diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/AbstractAsyncMcpToolMethodCallback.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/AbstractAsyncMcpToolMethodCallback.java index a4c459173c..7ddcb102f6 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/AbstractAsyncMcpToolMethodCallback.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/AbstractAsyncMcpToolMethodCallback.java @@ -20,6 +20,7 @@ import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.Content; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -80,24 +81,40 @@ protected Mono convertToCallToolResult(Object result) { .build())); } - // Handle Flux by taking the first element + // Handle Flux by collecting all elements if (result instanceof Flux) { Flux fluxResult = (Flux) result; - // Check if the Flux contains CallToolResult + // Check if the Flux contains CallToolResult — merge all content items if (ReactiveUtils.isReactiveReturnTypeOfCallToolResult(this.toolMethod)) { - return ((Flux) fluxResult).next(); + return ((Flux) fluxResult).collectList().map(results -> { + var builder = CallToolResult.builder(); + for (CallToolResult r : results) { + for (Content c : r.content()) { + builder.addContent(c); + } + } + return builder.build(); + }); } - // Handle Mono for VOID return type + // Handle Flux for VOID return type if (ReactiveUtils.isReactiveReturnTypeOfVoid(this.toolMethod)) { return fluxResult .then(Mono.just(CallToolResult.builder().addTextContent(JsonParser.toJson("Done")).build())); } - // Handle other Flux types by taking the first element and mapping - return fluxResult.next() - .map(this::mapValueToCallToolResult) + // Handle other Flux types by collecting all elements + return fluxResult.collectList().map(items -> { + var builder = CallToolResult.builder(); + for (Object item : items) { + CallToolResult itemResult = this.mapValueToCallToolResult(item); + for (Content c : itemResult.content()) { + builder.addContent(c); + } + } + return builder.build(); + }) .onErrorResume(e -> Mono.just(CallToolResult.builder() .isError(true) .addTextContent("Error invoking method: %s".formatted(e.getMessage())) diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/method/tool/AsyncMcpToolMethodCallbackTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/method/tool/AsyncMcpToolMethodCallbackTests.java index 7f26fd60e2..3c4fb7f303 100644 --- a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/method/tool/AsyncMcpToolMethodCallbackTests.java +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/method/tool/AsyncMcpToolMethodCallbackTests.java @@ -452,13 +452,18 @@ public void testMultipleFluxTool() throws Exception { McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); CallToolRequest request = new CallToolRequest("multiple-flux-tool", Map.of("prefix", "item")); - // Flux tools should take the first element + // Flux tools should collect all elements and return them as separate content + // items StepVerifier.create(callback.apply(exchange, request)).assertNext(result -> { assertThat(result).isNotNull(); assertThat(result.isError()).isFalse(); - assertThat(result.content()).hasSize(1); + assertThat(result.content()).hasSize(3); assertThat(result.content().get(0)).isInstanceOf(TextContent.class); assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("item1"); + assertThat(result.content().get(1)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(1)).text()).isEqualTo("item2"); + assertThat(result.content().get(2)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(2)).text()).isEqualTo("item3"); }).verifyComplete(); } diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/method/tool/AsyncStatelessMcpToolMethodCallbackTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/method/tool/AsyncStatelessMcpToolMethodCallbackTests.java index ac2d4605af..e8f174d7c3 100644 --- a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/method/tool/AsyncStatelessMcpToolMethodCallbackTests.java +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/method/tool/AsyncStatelessMcpToolMethodCallbackTests.java @@ -473,13 +473,18 @@ public void testMultipleFluxTool() throws Exception { McpTransportContext context = mock(McpTransportContext.class); CallToolRequest request = new CallToolRequest("multiple-flux-tool", Map.of("prefix", "item")); - // Flux tools should take the first element + // Flux tools should collect all elements and return them as separate content + // items StepVerifier.create(callback.apply(context, request)).assertNext(result -> { assertThat(result).isNotNull(); assertThat(result.isError()).isFalse(); - assertThat(result.content()).hasSize(1); + assertThat(result.content()).hasSize(3); assertThat(result.content().get(0)).isInstanceOf(TextContent.class); assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("item1"); + assertThat(result.content().get(1)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(1)).text()).isEqualTo("item2"); + assertThat(result.content().get(2)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(2)).text()).isEqualTo("item3"); }).verifyComplete(); } 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 3afa6deba4..22c1a9e6e1 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 @@ -533,11 +533,13 @@ public Flux fluxHandlingTool(String input) { StepVerifier.create(result).assertNext(callToolResult -> { assertThat(callToolResult).isNotNull(); assertThat(callToolResult.isError()).isFalse(); - assertThat(callToolResult.content()).hasSize(1); + assertThat(callToolResult.content()).hasSize(3); assertThat(callToolResult.content().get(0)).isInstanceOf(TextContent.class); - // Flux results are typically concatenated or collected into a single response - String content = ((TextContent) callToolResult.content().get(0)).text(); - assertThat(content).contains("test"); + assertThat(((TextContent) callToolResult.content().get(0)).text()).isEqualTo("Item1: test"); + assertThat(callToolResult.content().get(1)).isInstanceOf(TextContent.class); + assertThat(((TextContent) callToolResult.content().get(1)).text()).isEqualTo("Item2: test"); + assertThat(callToolResult.content().get(2)).isInstanceOf(TextContent.class); + assertThat(((TextContent) callToolResult.content().get(2)).text()).isEqualTo("Item3: test"); }).verifyComplete(); } @@ -956,18 +958,13 @@ public Flux listResponseTool(String input) { assertThat(result).isNotNull(); assertThat(result.isError()).isFalse(); - assertThat(result.content()).hasSize(1); + assertThat(result.content()).hasSize(3); assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class); - - String jsonText = ((TextContent) result.content().get(0)).text(); - System.out.println("Actual JSON output: " + jsonText); - - // The Flux might be serialized differently than expected, let's check what we - // actually get - // Based on the error, it seems like we're getting a single object instead of an - // array - // Let's adjust our assertion to match the actual behavior - assertThat(jsonText).contains("Processed: test - Item 1"); + assertThat(((TextContent) result.content().get(0)).text()).contains("Processed: test - Item 1"); + assertThat(result.content().get(1)).isInstanceOf(McpSchema.TextContent.class); + assertThat(((TextContent) result.content().get(1)).text()).contains("Processed: test - Item 2"); + assertThat(result.content().get(2)).isInstanceOf(McpSchema.TextContent.class); + assertThat(((TextContent) result.content().get(2)).text()).contains("Processed: test - Item 3"); } @Test 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 8c1511d6b6..a75bda4497 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 @@ -529,11 +529,13 @@ public Flux fluxHandlingTool(String input) { StepVerifier.create(result).assertNext(callToolResult -> { assertThat(callToolResult).isNotNull(); assertThat(callToolResult.isError()).isFalse(); - assertThat(callToolResult.content()).hasSize(1); + assertThat(callToolResult.content()).hasSize(3); assertThat(callToolResult.content().get(0)).isInstanceOf(TextContent.class); - // Flux results are typically concatenated or collected into a single response - String content = ((TextContent) callToolResult.content().get(0)).text(); - assertThat(content).contains("test"); + assertThat(((TextContent) callToolResult.content().get(0)).text()).isEqualTo("Item1: test"); + assertThat(callToolResult.content().get(1)).isInstanceOf(TextContent.class); + assertThat(((TextContent) callToolResult.content().get(1)).text()).isEqualTo("Item2: test"); + assertThat(callToolResult.content().get(2)).isInstanceOf(TextContent.class); + assertThat(((TextContent) callToolResult.content().get(2)).text()).isEqualTo("Item3: test"); }).verifyComplete(); } diff --git a/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekApi.java b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekApi.java index 4c3925a6f0..5a39ae123f 100644 --- a/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekApi.java +++ b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekApi.java @@ -547,6 +547,10 @@ public static class ToolChoiceBuilder { * Model will not call a function and instead generates a message */ public static final String NONE = "none"; + /** + * Model must call at least one tool, but it can choose which one. + */ + public static final String REQUIRED = "required"; /** * Specifying a particular function forces the model to call that function. diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java index 275abacb87..fe2ab7913b 100644 --- a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java @@ -625,6 +625,10 @@ public static class ToolChoiceBuilder { * Model will not call a function and instead generates a message */ public static final String NONE = "none"; + /** + * Model must call at least one tool, but it can choose which one. + */ + public static final String REQUIRED = "required"; /** * Specifying a particular function forces the model to call that function.