From 4027b90fd218e52477f5ae938557eb1dc59a337a Mon Sep 17 00:00:00 2001 From: "huidong.yin" Date: Mon, 4 Aug 2025 20:55:05 +0800 Subject: [PATCH 1/4] Fix incorrect example code in documentation Signed-off-by: huidong.yin --- .../src/main/antora/modules/ROOT/pages/api/tools.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc index c285ef58dbc..08e8713e6f2 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc @@ -789,7 +789,7 @@ When building tools from a method, the `ToolDefinition` is automatically generat [source,java] ---- Method method = ReflectionUtils.findMethod(DateTimeTools.class, "getCurrentDateTime"); -ToolDefinition toolDefinition = ToolDefinition.from(method); +ToolDefinition toolDefinition = ToolDefinitions.from(method); ---- The `ToolDefinition` generated from a method includes the method name as the tool name, the method name as the tool description, and the JSON schema of the method input parameters. If the method is annotated with `@Tool`, the tool name and description will be taken from the annotation, if set. From 91aadd31a31f1d82a2fca9c505d031da2b317140 Mon Sep 17 00:00:00 2001 From: "huidong.yin" Date: Wed, 6 Aug 2025 22:47:59 +0800 Subject: [PATCH 2/4] Fix tool call with empty arguments in streaming mode - Add null/empty argument handling in DefaultToolCallingManager - Add similar handling in SyncMcpToolCallback and AsyncMcpToolCallback - Add comprehensive test coverage for the fix - Apply Spring Java format to fix code style violations Signed-off-by: huidong.yin --- .../ai/mcp/AsyncMcpToolCallback.java | 14 +- .../ai/mcp/SyncMcpToolCallback.java | 10 +- .../model/tool/DefaultToolCallingManager.java | 24 ++- .../tool/DefaultToolCallingManagerTest.java | 164 ++++++++++++++++++ 4 files changed, 199 insertions(+), 13 deletions(-) create mode 100644 spring-ai-model/src/test/java/org/springframework/ai/model/tool/DefaultToolCallingManagerTest.java diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java b/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java index 5f8da416109..0f528e54c33 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java @@ -19,8 +19,8 @@ import io.modelcontextprotocol.client.McpAsyncClient; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.Tool; -import java.util.Map; - +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.model.tool.internal.ToolCallReactiveContextHolder; @@ -29,6 +29,8 @@ import org.springframework.ai.tool.definition.ToolDefinition; import org.springframework.ai.tool.execution.ToolExecutionException; +import java.util.Map; + /** * Implementation of {@link ToolCallback} that adapts MCP tools to Spring AI's tool * interface with asynchronous execution support. @@ -61,6 +63,8 @@ */ public class AsyncMcpToolCallback implements ToolCallback { + private static final Logger logger = LoggerFactory.getLogger(AsyncMcpToolCallback.class); + private final McpAsyncClient asyncMcpClient; private final Tool tool; @@ -109,6 +113,12 @@ public ToolDefinition getToolDefinition() { */ @Override public String call(String functionInput) { + // Handle the possible null parameter situation in streaming mode. + if (functionInput == null || functionInput.trim().isEmpty()) { + logger.debug("Tool call arguments are null or empty for MCP tool: {}. Using empty JSON object as default.", this.tool.name()); + functionInput = "{}"; + } + Map arguments = ModelOptionsUtils.jsonToMap(functionInput); // Note that we use the original tool name here, not the adapted one from // getToolDefinition diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java b/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java index fc61d801df1..262928c69d9 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java @@ -20,10 +20,8 @@ import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; import io.modelcontextprotocol.spec.McpSchema.Tool; -import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.tool.ToolCallback; @@ -31,6 +29,8 @@ import org.springframework.ai.tool.definition.ToolDefinition; import org.springframework.ai.tool.execution.ToolExecutionException; +import java.util.Map; + /** * Implementation of {@link ToolCallback} that adapts MCP tools to Spring AI's tool * interface. @@ -114,6 +114,12 @@ public ToolDefinition getToolDefinition() { */ @Override public String call(String functionInput) { + // Handle the possible null parameter situation in streaming mode. + if (functionInput == null || functionInput.trim().isEmpty()) { + logger.debug("Tool call arguments are null or empty for MCP tool: {}. Using empty JSON object as default.", this.tool.name()); + functionInput = "{}"; + } + Map arguments = ModelOptionsUtils.jsonToMap(functionInput); CallToolResult response; diff --git a/spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java b/spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java index 5149a98a85c..91cf97200f1 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java @@ -16,16 +16,9 @@ package org.springframework.ai.model.tool; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - import io.micrometer.observation.ObservationRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.ToolResponseMessage; @@ -47,6 +40,8 @@ import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; +import java.util.*; + /** * Default implementation of {@link ToolCallingManager}. * @@ -189,6 +184,17 @@ private InternalToolExecutionResult executeToolCall(Prompt prompt, AssistantMess String toolName = toolCall.name(); String toolInputArguments = toolCall.arguments(); + // Handle the possible null parameter situation in streaming mode. + final String finalToolInputArguments; + if (toolInputArguments == null || toolInputArguments.trim().isEmpty()) { + logger.debug("Tool call arguments are null or empty for tool: {}. Using empty JSON object as default.", + toolName); + finalToolInputArguments = "{}"; + } + else { + finalToolInputArguments = toolInputArguments; + } + ToolCallback toolCallback = toolCallbacks.stream() .filter(tool -> toolName.equals(tool.getToolDefinition().name())) .findFirst() @@ -208,7 +214,7 @@ private InternalToolExecutionResult executeToolCall(Prompt prompt, AssistantMess ToolCallingObservationContext observationContext = ToolCallingObservationContext.builder() .toolDefinition(toolCallback.getToolDefinition()) .toolMetadata(toolCallback.getToolMetadata()) - .toolCallArguments(toolInputArguments) + .toolCallArguments(finalToolInputArguments) .build(); String toolCallResult = ToolCallingObservationDocumentation.TOOL_CALL @@ -217,7 +223,7 @@ private InternalToolExecutionResult executeToolCall(Prompt prompt, AssistantMess .observe(() -> { String toolResult; try { - toolResult = toolCallback.call(toolInputArguments, toolContext); + toolResult = toolCallback.call(finalToolInputArguments, toolContext); } catch (ToolExecutionException ex) { toolResult = this.toolExecutionExceptionProcessor.process(ex); diff --git a/spring-ai-model/src/test/java/org/springframework/ai/model/tool/DefaultToolCallingManagerTest.java b/spring-ai-model/src/test/java/org/springframework/ai/model/tool/DefaultToolCallingManagerTest.java new file mode 100644 index 00000000000..ceb84a0a941 --- /dev/null +++ b/spring-ai-model/src/test/java/org/springframework/ai/model/tool/DefaultToolCallingManagerTest.java @@ -0,0 +1,164 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.tool; + +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.DefaultToolDefinition; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * Tests for {@link DefaultToolCallingManager} with empty/null arguments handling. + * + * @author Spring AI Team + */ +class DefaultToolCallingManagerTest { + + @Test + void shouldHandleNullArgumentsInStreamMode() { + // Create a mock tool callback + ToolCallback mockToolCallback = new ToolCallback() { + @Override + public ToolDefinition getToolDefinition() { + return DefaultToolDefinition.builder() + .name("testTool") + .description("A test tool") + .inputSchema("{}") + .build(); + } + + @Override + public ToolMetadata getToolMetadata() { + return ToolMetadata.builder().build(); + } + + @Override + public String call(String toolInput) { + // Verify the input is not null or empty + assertThat(toolInput).isNotNull(); + assertThat(toolInput).isNotEmpty(); + return "{\"result\": \"success\"}"; + } + }; + + // Create DefaultToolCallingManager with tool callback + DefaultToolCallingManager manager = DefaultToolCallingManager.builder() + .observationRegistry(ObservationRegistry.NOOP) + .build(); + + // Create a ToolCall with empty parameters + AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall("1", "function", "testTool", null); + + // Create a ChatResponse + AssistantMessage assistantMessage = new AssistantMessage("", Map.of(), List.of(toolCall)); + Generation generation = new Generation(assistantMessage); + ChatResponse chatResponse = new ChatResponse(List.of(generation)); + + // Create a Prompt with tool callbacks + Prompt prompt = new Prompt(List.of(new UserMessage("test"))); + + // Mock the tool callbacks resolution by creating a custom ToolCallbackResolver + DefaultToolCallingManager managerWithCallback = DefaultToolCallingManager.builder() + .observationRegistry(ObservationRegistry.NOOP) + .toolCallbackResolver(toolName -> { + if ("testTool".equals(toolName)) { + return mockToolCallback; + } + return null; + }) + .build(); + + // Verify that no exception is thrown + assertThatNoException().isThrownBy(() -> { + managerWithCallback.executeToolCalls(prompt, chatResponse); + }); + } + + @Test + void shouldHandleEmptyArgumentsInStreamMode() { + // Create a mock tool callback + ToolCallback mockToolCallback = new ToolCallback() { + @Override + public ToolDefinition getToolDefinition() { + return DefaultToolDefinition.builder() + .name("testTool") + .description("A test tool") + .inputSchema("{}") + .build(); + } + + @Override + public ToolMetadata getToolMetadata() { + return ToolMetadata.builder().build(); + } + + @Override + public String call(String toolInput) { + // Verify the input is not null or empty + assertThat(toolInput).isNotNull(); + assertThat(toolInput).isNotEmpty(); + return "{\"result\": \"success\"}"; + } + }; + + // Create DefaultToolCallingManager with tool callback + DefaultToolCallingManager manager = DefaultToolCallingManager.builder() + .observationRegistry(ObservationRegistry.NOOP) + .build(); + + // Create a ToolCall with empty parameters + AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall("1", "function", "testTool", ""); + + // Create a ChatResponse + AssistantMessage assistantMessage = new AssistantMessage("", Map.of(), List.of(toolCall)); + Generation generation = new Generation(assistantMessage); + ChatResponse chatResponse = new ChatResponse(List.of(generation)); + + // Create a Prompt with tool callbacks + Prompt prompt = new Prompt(List.of(new UserMessage("test"))); + + // Mock the tool callbacks resolution by creating a custom ToolCallbackResolver + DefaultToolCallingManager managerWithCallback = DefaultToolCallingManager.builder() + .observationRegistry(ObservationRegistry.NOOP) + .toolCallbackResolver(toolName -> { + if ("testTool".equals(toolName)) { + return mockToolCallback; + } + return null; + }) + .build(); + + // Verify that no exception is thrown + assertThatNoException().isThrownBy(() -> { + managerWithCallback.executeToolCalls(prompt, chatResponse); + }); + } + +} From 68f13962758cacdebdd574773ab0ede1abb85ab8 Mon Sep 17 00:00:00 2001 From: "huidong.yin" Date: Wed, 6 Aug 2025 22:57:19 +0800 Subject: [PATCH 3/4] Apply Spring Java format to fix MCP module code style violations Signed-off-by: huidong.yin --- .../org/springframework/ai/mcp/AsyncMcpToolCallback.java | 5 +++-- .../java/org/springframework/ai/mcp/SyncMcpToolCallback.java | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java b/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java index 0f528e54c33..bfb999b503e 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java @@ -115,10 +115,11 @@ public ToolDefinition getToolDefinition() { public String call(String functionInput) { // Handle the possible null parameter situation in streaming mode. if (functionInput == null || functionInput.trim().isEmpty()) { - logger.debug("Tool call arguments are null or empty for MCP tool: {}. Using empty JSON object as default.", this.tool.name()); + logger.debug("Tool call arguments are null or empty for MCP tool: {}. Using empty JSON object as default.", + this.tool.name()); functionInput = "{}"; } - + Map arguments = ModelOptionsUtils.jsonToMap(functionInput); // Note that we use the original tool name here, not the adapted one from // getToolDefinition diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java b/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java index 262928c69d9..df6cfa23cd5 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java @@ -116,10 +116,11 @@ public ToolDefinition getToolDefinition() { public String call(String functionInput) { // Handle the possible null parameter situation in streaming mode. if (functionInput == null || functionInput.trim().isEmpty()) { - logger.debug("Tool call arguments are null or empty for MCP tool: {}. Using empty JSON object as default.", this.tool.name()); + logger.debug("Tool call arguments are null or empty for MCP tool: {}. Using empty JSON object as default.", + this.tool.name()); functionInput = "{}"; } - + Map arguments = ModelOptionsUtils.jsonToMap(functionInput); CallToolResult response; From 1ad800aeb8dcce7eff5fe43f418174d33bb5bda0 Mon Sep 17 00:00:00 2001 From: "huidong.yin" Date: Wed, 6 Aug 2025 23:22:16 +0800 Subject: [PATCH 4/4] Fix wildcard import in DefaultToolCallingManager - Replace java.util.* with specific imports - Apply Spring Java format to ensure code style compliance Signed-off-by: huidong.yin --- .../ai/model/tool/DefaultToolCallingManager.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java b/spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java index 91cf97200f1..53bad12512a 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java @@ -40,7 +40,11 @@ import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; /** * Default implementation of {@link ToolCallingManager}.