From 5e9642365f1d3e2284c0ef9373b761257aefee34 Mon Sep 17 00:00:00 2001 From: Gorre Surya Date: Mon, 25 May 2026 22:22:47 -0400 Subject: [PATCH 1/2] feat: add REQUIRED constant to ToolChoiceBuilder in DeepSeek and MiniMax APIs Adds the missing `REQUIRED` tool_choice constant that forces the model to call at least one tool without specifying which one, aligning with the OpenAI spec. Fixes: spring-projects/spring-ai#6120 Signed-off-by: Gorre Surya --- .../java/org/springframework/ai/deepseek/api/DeepSeekApi.java | 4 ++++ .../java/org/springframework/ai/minimax/api/MiniMaxApi.java | 4 ++++ 2 files changed, 8 insertions(+) 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. From bbdcb4276be9799ad7c5c0864ecec28fb2881e41 Mon Sep 17 00:00:00 2001 From: Gorre Surya Date: Mon, 25 May 2026 22:41:09 -0400 Subject: [PATCH 2/2] fix: collect all Flux elements in async MCP tool callbacks AsyncMcpToolMethodCallback and AsyncStatelessMcpToolMethodCallback previously called .next() on Flux return types, discarding all but the first emitted element. Tools returning Flux now have all items collected and returned as separate CallToolResult content entries. Fixes: spring-projects/spring-ai#4542 Signed-off-by: Gorre Surya --- .../AbstractAsyncMcpToolMethodCallback.java | 31 ++++++++++++++----- .../tool/AsyncMcpToolMethodCallbackTests.java | 9 ++++-- ...ncStatelessMcpToolMethodCallbackTests.java | 9 ++++-- .../tool/AsyncMcpToolProviderTests.java | 27 +++++++--------- .../AsyncStatelessMcpToolProviderTests.java | 10 +++--- 5 files changed, 56 insertions(+), 30 deletions(-) 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(); }